feat(bridge): establish local pi status bridge

Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
This commit is contained in:
2026-07-27 14:51:45 +02:00
commit b7fea83ed6
91 changed files with 15029 additions and 0 deletions
@@ -0,0 +1,89 @@
local request_in_flight = false
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\\\"'\\\"'") .. "'"
end
local function ui_binary()
local configured = noctalia.getenv("PI_STATUS_UI_BINARY")
if configured ~= nil and configured ~= "" then return configured end
return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui"
end
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local function current_agent_id()
return noctalia.state.get("agent_id")
end
local function discover_home_agent(socket)
if request_in_flight then return end
request_in_flight = true
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
request_in_flight = false
if result.exitCode ~= 0 then return end
local home = noctalia.getenv("HOME")
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
if worktree == home then
noctalia.state.set("agent_id", id)
noctalia.state.set("project_path", worktree)
return
end
end
end)
end
function update()
noctalia.setUpdateInterval(2000)
local agent_id = current_agent_id()
local socket = socket_path()
if socket == nil then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi offline")
barWidget.setTooltip("Bridge socket is unavailable")
return
end
if agent_id == nil then
discover_home_agent(socket)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi home")
barWidget.setTooltip("Starting the default Home agent; click to open Pi Status UI")
return
end
if request_in_flight then return end
request_in_flight = true
local command = "pi-status-bridge-client --socket \"" .. socket
.. "\" request '{\"op\":\"get_state\",\"agentId\":\"" .. agent_id .. "\"}'"
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
noctalia.state.set("agent_id", nil)
discover_home_agent(socket)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing the Home Pi agent")
return
end
local streaming = string.find(result.stdout, "\"isStreaming\":true", 1, true) ~= nil
barWidget.setGlyph(streaming and "terminal-2" or "terminal")
barWidget.setText(streaming and "Pi working" or "Pi ready")
barWidget.setTooltip(noctalia.state.get("project_path") or "Selected Pi folder")
end)
end
function onClick()
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
.. " --show >/tmp/pi-status-ui.log 2>&1"
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
barWidget.setGlyph("alert-triangle")
barWidget.setText("Pi UI unavailable")
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
end
end)
end
@@ -0,0 +1,360 @@
local agent_id = nil
local agents = {}
local messages = {}
local folder_path = noctalia.getenv("HOME") or ""
local composer = ""
local extension_input = ""
local pending_extension = nil
local status = "Loading Pi agents…"
local request_in_flight = false
local stream_started = false
local view = "output"
local command_suggestions = {}
local models = {}
local model_options = {}
local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" }
local thinking_level = "medium"
local session_summary = "Session loading…"
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
end
local function request(op, agent, payload, callback)
local socket = socket_path()
if socket == nil then
status = "Bridge socket is unavailable."
render_panel()
return
end
local body = { op = op }
if agent ~= nil then body.agentId = agent end
if payload ~= nil then body.payload = payload end
local encoded, err = noctalia.json.encode(body)
if encoded == nil then
status = "Could not encode bridge request: " .. tostring(err)
render_panel()
return
end
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded)
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
status = "Bridge request failed: " .. noctalia.string.trim(result.stderr)
render_panel()
return
end
local decoded, decode_error = noctalia.json.decode(result.stdout)
if decoded == nil then
status = "Invalid bridge response: " .. tostring(decode_error)
render_panel()
return
end
callback(decoded)
end)
end
local function message_text(message)
if message == nil then return "" end
if type(message.content) == "string" then return message.content end
if type(message.content) ~= "table" then return "" end
local parts = {}
for _, block in ipairs(message.content) do
if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end
if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end
if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end
end
return table.concat(parts, "\n")
end
local function choose_home_agent()
local home = noctalia.getenv("HOME")
for _, agent in ipairs(agents) do
if agent.worktreePath == home then return agent.id end
end
return agents[1] and agents[1].id or nil
end
local function known_agent(id)
for _, agent in ipairs(agents) do
if agent.id == id then return true end
end
return false
end
local function refresh_agents()
request("list_agents", nil, nil, function(result)
agents = result.agents or {}
local remembered_id = agent_id or noctalia.state.get("agent_id")
agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent()
if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end
status = agent_id and "Pi ready" or "Add a folder to start Pi"
render_panel()
if agent_id ~= nil then
refresh_transcript()
refresh_commands()
refresh_controls()
end
end)
end
function refresh_controls()
if agent_id == nil then return end
request("get_state", agent_id, nil, function(result)
local state = result.data or {}
thinking_level = state.thinkingLevel or thinking_level
session_summary = (state.sessionName or state.sessionId or "Session")
.. "" .. tostring(state.messageCount or 0) .. " messages"
.. "" .. tostring(state.pendingMessageCount or 0) .. " queued"
status = state.isStreaming and "Pi working" or "Pi ready"
request("get_available_models", agent_id, nil, function(models_result)
models = (models_result.data and models_result.data.models) or {}
model_options = {}
for _, model in ipairs(models) do
table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model"))
end
render_panel()
end)
end)
end
function refresh_commands()
if agent_id == nil then return end
request("get_commands", agent_id, nil, function(result)
command_suggestions = {}
for _, command in ipairs((result.data and result.data.commands) or {}) do
if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end
end
render_panel()
end)
end
function refresh_transcript()
if agent_id == nil or request_in_flight then return end
request_in_flight = true
request("get_transcript", agent_id, nil, function(result)
request_in_flight = false
messages = result.data and result.data.messages or {}
render_panel()
end)
end
local function start_stream()
if stream_started or agent_id == nil then return end
local socket = socket_path()
if socket == nil then return end
stream_started = true
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id)
noctalia.runStream(command, function(line)
local event = noctalia.json.decode(line)
if event == nil then return end
if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end
if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end
if event.type == "queue" then status = "Pi queue updated" end
if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then
status = "Tool: " .. tostring(event.data.event.toolName or "running")
end
if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end
render_panel()
end)
end
local function agent_label(agent)
return agent.worktreePath .. "" .. agent.state
end
function render_panel()
local selected_path = noctalia.state.get("project_path") or "Home"
local children = {
ui.row({ gap = 8, justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }),
ui.label({ text = selected_path .. "" .. status })
}),
ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" })
})
}
if view == "settings" then
table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" }))
local choices = {}
for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end
if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end
table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" }))
table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" }))
table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." }))
table.insert(children, ui.label({ text = session_summary, maxLines = 2 }))
table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" }))
if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end
table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" }))
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Retry", onClick = "onRetry" }),
ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" })
}))
else
table.insert(children, ui.label({ text = "Output", fontWeight = "bold" }))
local transcript = {}
for _, message in ipairs(messages) do
local text = message_text(message)
if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end
end
if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end
table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript))
table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" }))
if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then
table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" }))
end
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }),
ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" })
}))
end
if pending_extension ~= nil then
local method = pending_extension.method or "input"
table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" }))
table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." }))
if method == "select" and pending_extension.options ~= nil then
table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" }))
elseif method == "confirm" then
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }),
ui.button({ text = "Decline", onClick = "onExtensionDecline" })
}))
elseif method == "input" or method == "editor" then
table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" }))
table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" }))
else
table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 }))
end
table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" }))
end
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
end
function onOpen()
panel.setWantsSecondTicks(true)
refresh_agents()
render_panel()
end
function update()
refresh_transcript()
end
function onFolderChanged(value) folder_path = value end
function onComposerChanged(value)
composer = value
if string.sub(composer, 1, 1) == "/" then render_panel() end
end
function onCommandSuggestion(index)
local suggestion = command_suggestions[tonumber(index) + 1]
if suggestion ~= nil then
composer = suggestion .. " "
render_panel()
end
end
function onExtensionInputChanged(value) extension_input = value end
function onSettingsAgentChange(index)
local agent = agents[tonumber(index) + 1]
if agent == nil then return end
agent_id = agent.id
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", agent.worktreePath)
stream_started = false
start_stream()
refresh_transcript()
end
function onModelChange(index)
local model = models[tonumber(index) + 1]
if model == nil or agent_id == nil then return end
request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end)
end
function onThinkingChange(index)
local level = thinking_options[tonumber(index) + 1]
if level == nil or agent_id == nil then return end
request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end)
end
function onRetry()
if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end
end
function onRestart()
if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end
end
function onAddFolder()
if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end
request("select_agent", nil, { worktreePath = folder_path }, function(result)
agent_id = result.agent and result.agent.id or nil
if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", folder_path)
status = "Pi ready for " .. folder_path
stream_started = false
start_stream()
refresh_agents()
end)
end
local function submit_composer()
if agent_id == nil then status = "Choose a folder first."; render_panel(); return end
if composer == "" then return end
request("submit_prompt", agent_id, { message = composer }, function(_)
composer = ""
status = "Prompt queued by Pi"
refresh_transcript()
end)
end
function onPrompt() submit_composer() end
function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end
local function send_extension_response(response)
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onExtensionSubmit()
send_extension_response({ value = extension_input })
end
function onExtensionSelect(index)
if pending_extension == nil or pending_extension.options == nil then return end
local option = pending_extension.options[tonumber(index) + 1]
if option ~= nil then send_extension_response({ value = option }) end
end
function onExtensionConfirm()
send_extension_response({ confirmed = true })
end
function onExtensionDecline()
send_extension_response({ confirmed = false })
end
function onExtensionCancel()
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onClosePicker()
panel.close()
end
@@ -0,0 +1,10 @@
id = "alex/pi-status-bridge"
name = "Pi Status Bridge"
version = "0.2.0"
plugin_api = 3
author = "alex"
description = "Compact Pi status and launcher for the standalone Pi Status UI."
[[widget]]
id = "bridge"
entry = "bridge.luau"