* Add OpenCode Panel plugin Introduce initial implementation of the OpenCode Panel plugin. This commit includes: - Bar widget for status and quick actions. - Chat panel with session selection, message history, and input. - Background service for OpenCode server management, SSE event handling, and state persistence. - Plugin configuration, documentation, and internationalization. * Refine session chooser UI layout Remove an unnecessary spacer and apply top-justification to improve vertical alignment. Add a subtle background fill to the session list. * Introduce session search in chooser Enable live filtering of sessions in the chooser. Matches against title, slug, ID, and directory. Search is case-insensitive. * Support multi-question agent requests and clear composer Implement multi-question agent replies by accumulating choices locally. A "Submit" button becomes enabled only after all questions are answered. The chat composer clears its text after sending by updating its key, forcing the UI to re-render an empty input field. Updated translations for send hints and question submit button. * Add UI mode setting for compact layout Introduce a `ui_mode` setting (Full/Compact) that dynamically adjusts panel layout. Compact mode reduces padding, font sizes, and gaps, hiding some secondary details to minimize the panel footprint. Layout metrics recompute on every render based on this setting. * Delete opencode-panel * Update thumbnail.webp * Render chat messages newest-first Newest message shows at top. Scrolling down reveals older messages. This avoids scroll offset resets on re-mount in older shell APIs. Thinking bubble now appears above the newest message. * FEAT: Add panel layout setting Provide option for panel to fill right side or appear compact near click. Introduces a new panel entry for the "fill right" mode. * Rename plugin to OpenCode Companion Update plugin ID, panel entries, IPC commands, and state paths. * Only send model override when known Stale default_model now falls back to no override instead of failing every turn. Warn when configured default unavailable. * Update service.luau * fix(opencode-companion): secure terminal open, publish MCP status, respect auto_start - Shell-quote host and session id in the "open in terminal" command so a crafted server_host cannot inject extra shell commands - Publish opencode.mcp_status and render it as a collapsible footer (previously declared but never observable) - Honor auto_start: skip managed-server start on load when set to false - Fix broken thumbnail link in README (assets/thumbnail.webp -> thumbnail.webp) * fix Terminal command injection,MCP status publication and UI
1470 lines
54 KiB
Luau
1470 lines
54 KiB
Luau
-- OpenCode Companion service — the headless backend and single source of truth.
|
|
--
|
|
-- Responsibilities:
|
|
-- • Manage the OpenCode server lifecycle (auto or external mode)
|
|
-- • Maintain the SSE event stream connection
|
|
-- • Track active session, messages, and connection state
|
|
-- • Handle permission requests
|
|
-- • Publish shared state for widget and panel subscribers
|
|
-- • Persist boot-scoped session state
|
|
--
|
|
-- The widget and panel are pure subscribers of `opencode.*` state keys.
|
|
|
|
-- ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
-- Translation with an optional per-plugin language override (mirrors panel.luau).
|
|
-- noctalia.tr() follows the global shell locale, so a specific `language` choice
|
|
-- loads the plugin's own translations/<lang>.json (merged over en.json) and
|
|
-- resolves keys locally. "auto" delegates to noctalia.tr.
|
|
local i18n_cache = { lang = nil, table = nil }
|
|
|
|
local function load_lang_table(lang)
|
|
if i18n_cache.lang == lang and i18n_cache.table then
|
|
return i18n_cache.table
|
|
end
|
|
local merged = {}
|
|
local function merge_file(path)
|
|
local ok, content = pcall(noctalia.readFile, path)
|
|
if not ok or type(content) ~= "string" or content == "" then return end
|
|
local ok2, data = pcall(noctalia.json.decode, content)
|
|
if not ok2 or type(data) ~= "table" then return end
|
|
local function deep(dst, src)
|
|
for k, v in pairs(src) do
|
|
if type(v) == "table" and type(dst[k]) == "table" then
|
|
deep(dst[k], v)
|
|
else
|
|
dst[k] = v
|
|
end
|
|
end
|
|
end
|
|
deep(merged, data)
|
|
end
|
|
merge_file("translations/en.json")
|
|
if lang ~= "en" then
|
|
merge_file("translations/" .. lang .. ".json")
|
|
end
|
|
i18n_cache.lang = lang
|
|
i18n_cache.table = merged
|
|
return merged
|
|
end
|
|
|
|
local function tr(key, args)
|
|
local lang = noctalia.getConfig and noctalia.getConfig("language")
|
|
if type(lang) ~= "string" or lang == "" or lang == "auto" then
|
|
return noctalia.tr(key, args)
|
|
end
|
|
local node = load_lang_table(lang)
|
|
for part in string.gmatch(key, "[^%.]+") do
|
|
if type(node) ~= "table" then node = nil break end
|
|
node = node[part]
|
|
end
|
|
if type(node) ~= "string" then
|
|
return noctalia.tr(key, args)
|
|
end
|
|
if type(args) == "table" then
|
|
node = string.gsub(node, "{(%w+)}", function(name)
|
|
local v = args[name]
|
|
if v == nil then return "{" .. name .. "}" end
|
|
return tostring(v)
|
|
end)
|
|
end
|
|
return node
|
|
end
|
|
|
|
local function debug_log(msg)
|
|
if noctalia.getConfig and noctalia.getConfig("debug_logging") then
|
|
print("[opencode-companion] " .. tostring(msg))
|
|
end
|
|
end
|
|
|
|
-- Shell-quote for safe command composition
|
|
local function shq(s)
|
|
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
-- Safe id validation for IPC / URL composition
|
|
local function safe_id(id)
|
|
return type(id) == "string" and id ~= "" and id:match("^[%w%._%-]+$") ~= nil
|
|
end
|
|
|
|
-- ── config ───────────────────────────────────────────────────────────────────
|
|
|
|
local function get_server_mode()
|
|
local v = noctalia.getConfig and noctalia.getConfig("server_mode")
|
|
return (type(v) == "string" and v ~= "") and v or "auto"
|
|
end
|
|
|
|
local function get_server_host()
|
|
local v = noctalia.getConfig and noctalia.getConfig("server_host")
|
|
return (type(v) == "string" and v ~= "") and v or "127.0.0.1"
|
|
end
|
|
|
|
local function get_server_port()
|
|
local v = noctalia.getConfig and noctalia.getConfig("server_port")
|
|
return (type(v) == "number" and v > 0) and v or 4096
|
|
end
|
|
|
|
local function get_server_url()
|
|
local v = noctalia.getConfig and noctalia.getConfig("server_url")
|
|
return (type(v) == "string") and v or ""
|
|
end
|
|
|
|
local function get_auto_start()
|
|
local v = noctalia.getConfig and noctalia.getConfig("auto_start")
|
|
return v ~= false
|
|
end
|
|
|
|
local function get_default_workspace()
|
|
local v = noctalia.getConfig and noctalia.getConfig("default_workspace")
|
|
return (type(v) == "string") and v or ""
|
|
end
|
|
|
|
local function get_default_model()
|
|
local v = noctalia.getConfig and noctalia.getConfig("default_model")
|
|
return (type(v) == "string") and v or ""
|
|
end
|
|
|
|
local function get_default_agent()
|
|
local v = noctalia.getConfig and noctalia.getConfig("default_agent")
|
|
return (type(v) == "string" and v ~= "") and v or "build"
|
|
end
|
|
|
|
local function get_max_messages()
|
|
local v = noctalia.getConfig and noctalia.getConfig("max_messages_load")
|
|
local n = (type(v) == "number" and v > 0) and math.floor(v) or 50
|
|
-- The slider tops out at 101 which we treat as "unlimited": ask the server
|
|
-- for an effectively unbounded window so the whole session loads. The
|
|
-- server's /message?limit=N returns the N NEWEST messages (oldest-first
|
|
-- within that window), so a large N yields the full history.
|
|
if n >= 101 then
|
|
return 100000
|
|
end
|
|
return n
|
|
end
|
|
|
|
-- ── state ────────────────────────────────────────────────────────────────────
|
|
|
|
-- Connection state
|
|
local connection = {
|
|
status = "offline",
|
|
error = nil,
|
|
server_version = nil,
|
|
}
|
|
|
|
-- Server process info (managed mode only)
|
|
local managed_pid = nil
|
|
local managed_port = nil
|
|
|
|
-- Active session tracking
|
|
local active_session = nil
|
|
local sessions = {}
|
|
local messages = {}
|
|
local session_status = {}
|
|
local pending_permissions = {}
|
|
local pending_questions = {} -- [{ requestID, sessionID, questions, tool }]
|
|
local mcp_status = {}
|
|
local providers = {}
|
|
local agents = {}
|
|
local model_options = {} -- [{ value, label }] for the model picker
|
|
local agent_options = {} -- [{ value, label }] for the agent picker
|
|
local unread_count = 0
|
|
local last_error = nil
|
|
|
|
-- Optimistic user message appended on send, replaced once the server echoes it.
|
|
local optimistic_msg = nil
|
|
|
|
-- When the active session went busy (os.time seconds). Used to auto-recover a
|
|
-- stale busy status if the server never emits idle/error (dropped SSE event),
|
|
-- which otherwise locks the composer ("can only send once").
|
|
local busy_since = nil
|
|
local BUSY_STALE_S = 180
|
|
|
|
-- Cheap guard: skip re-decoding an identical messages payload. SSE fires many
|
|
-- part-updated events per reply; without this each one re-decodes the full
|
|
-- (often large) JSON and can blow the callback CPU budget.
|
|
local last_messages_body = nil
|
|
|
|
-- Runtime-selected model / agent (override the configured defaults for this
|
|
-- session without editing settings). Set via IPC from the panel selectors.
|
|
-- `selected_model` is ONLY set when the user explicitly picks one; when nil we
|
|
-- send no model override so the server uses the session's own working default.
|
|
-- Auto-seeding this from /config/providers `default` was the cause of the
|
|
-- "session encountered an error" bug: `default` can list several providers and
|
|
-- pairs() picked an arbitrary (possibly unauthenticated) one, forcing every
|
|
-- turn onto a model the server couldn't run.
|
|
local selected_model = nil -- "providerID/modelID" or nil = no override
|
|
local selected_agent = nil -- agent name or nil = no override
|
|
-- Model to preselect in the picker for display only (never auto-sent).
|
|
local default_model_display = nil
|
|
|
|
-- SSE connection state
|
|
local sse_stream = nil
|
|
local sse_reconnect_attempts = 0
|
|
local sse_max_reconnect = 10
|
|
|
|
-- Boot-scoped state
|
|
local boot_id = nil
|
|
local state_file = nil
|
|
|
|
-- Restore the previously-active session only once per boot. load_sessions()
|
|
-- runs after every delete/create/refresh, and without this guard it would
|
|
-- re-select the persisted session on every reload — making deleting a session
|
|
-- from the chooser "jump" into whatever session was last active.
|
|
local restore_done = false
|
|
|
|
-- Forward declarations: these functions are defined later in the file but are
|
|
-- referenced by earlier callbacks (health checks, session loading). Declaring
|
|
-- them up front keeps the references in scope so the async callbacks don't hit
|
|
-- a nil global when they fire.
|
|
local load_initial_data
|
|
local restore_active_session
|
|
local select_session
|
|
|
|
-- ── boot id persistence ──────────────────────────────────────────────────────
|
|
|
|
local function read_boot_id()
|
|
local ok, content = pcall(noctalia.readFile, "/proc/sys/kernel/random/boot_id")
|
|
if not ok or type(content) ~= "string" or content == "" then return nil end
|
|
local id = content:match("^[^\n\r]*")
|
|
if id then id = id:gsub("^%s+", ""):gsub("%s+$", "") end
|
|
return (id and id ~= "") and id or nil
|
|
end
|
|
|
|
local function load_boot_state()
|
|
if not state_file then return nil end
|
|
local ok, content = pcall(noctalia.readFile, state_file)
|
|
if not ok or type(content) ~= "string" or content == "" then return nil end
|
|
local ok2, data = pcall(noctalia.json.decode, content)
|
|
if not ok2 or type(data) ~= "table" then return nil end
|
|
return data
|
|
end
|
|
|
|
local function save_boot_state(state)
|
|
if not state_file then return end
|
|
local ok, encoded = pcall(noctalia.json.encode, state)
|
|
if not ok then return end
|
|
pcall(noctalia.writeFile, state_file, encoded)
|
|
end
|
|
|
|
local function clear_boot_state()
|
|
if not state_file then return end
|
|
pcall(noctalia.writeFile, state_file, "{}")
|
|
end
|
|
|
|
local function persist_active_session()
|
|
if not boot_id then return end
|
|
local data = load_boot_state() or {}
|
|
data.boot_id = boot_id
|
|
data.active_session_id = active_session and active_session.id or nil
|
|
data.workspace = get_default_workspace()
|
|
data.draft = nil -- panel handles draft persistence separately
|
|
save_boot_state(data)
|
|
end
|
|
|
|
local function restore_session_if_same_boot()
|
|
local data = load_boot_state()
|
|
if not data or type(data) ~= "table" then return false end
|
|
if data.boot_id ~= boot_id then
|
|
-- Different boot: clear stale session reference
|
|
clear_boot_state()
|
|
return false
|
|
end
|
|
if data.active_session_id and safe_id(data.active_session_id) then
|
|
-- Try to validate the session still exists
|
|
return data.active_session_id
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ── http client ──────────────────────────────────────────────────────────────
|
|
|
|
local API = {}
|
|
|
|
function API.request(method, path, body, callback)
|
|
local mode = get_server_mode()
|
|
local base_url
|
|
if mode == "external" then
|
|
base_url = get_server_url()
|
|
if base_url == "" then
|
|
callback({ ok = false, status = 0, body = "external server URL not configured" })
|
|
return
|
|
end
|
|
else
|
|
base_url = "http://" .. get_server_host() .. ":" .. tostring(get_server_port())
|
|
end
|
|
|
|
local url = base_url .. path
|
|
local headers = { "Accept: application/json" }
|
|
local body_str = nil
|
|
|
|
if body then
|
|
body_str = noctalia.json.encode(body)
|
|
table.insert(headers, "Content-Type: application/json")
|
|
end
|
|
|
|
local request = {
|
|
url = url,
|
|
method = method,
|
|
headers = headers,
|
|
body = body_str,
|
|
follow_redirects = false,
|
|
}
|
|
|
|
noctalia.http(request, callback)
|
|
end
|
|
|
|
function API.get(path, callback)
|
|
API.request("GET", path, nil, callback)
|
|
end
|
|
|
|
function API.post(path, body, callback)
|
|
API.request("POST", path, body, callback)
|
|
end
|
|
|
|
function API.patch(path, body, callback)
|
|
API.request("PATCH", path, body, callback)
|
|
end
|
|
|
|
function API.delete(path, callback)
|
|
API.request("DELETE", path, nil, callback)
|
|
end
|
|
|
|
function API.health(callback)
|
|
API.get("/global/health", callback)
|
|
end
|
|
|
|
-- ── server lifecycle ─────────────────────────────────────────────────────────
|
|
|
|
-- Dirty tracking: publish() only writes state keys that changed since the last
|
|
-- call. Publishing every key on every change is expensive (large tables like
|
|
-- sessions/messages get re-serialized each time) and blows the per-callback CPU
|
|
-- budget, which silently kills IPC/SSE handlers — the cause of "can only send
|
|
-- one message".
|
|
local dirty = {}
|
|
|
|
local function mark_dirty(key)
|
|
dirty[key] = true
|
|
end
|
|
|
|
local function publish()
|
|
if dirty.connection or dirty.all then
|
|
noctalia.state.set("opencode.connection", connection)
|
|
noctalia.state.set("opencode.server_version", connection.server_version)
|
|
end
|
|
if dirty.active_session or dirty.all then
|
|
noctalia.state.set("opencode.active_session", active_session)
|
|
end
|
|
if dirty.sessions or dirty.all then
|
|
noctalia.state.set("opencode.sessions", sessions)
|
|
end
|
|
if dirty.messages or dirty.all then
|
|
noctalia.state.set("opencode.messages", messages)
|
|
end
|
|
if dirty.session_status or dirty.all then
|
|
noctalia.state.set("opencode.session_status", session_status)
|
|
end
|
|
if dirty.mcp_status or dirty.all then
|
|
noctalia.state.set("opencode.mcp_status", mcp_status)
|
|
end
|
|
if dirty.pending_permissions or dirty.all then
|
|
noctalia.state.set("opencode.pending_permissions", pending_permissions)
|
|
end
|
|
if dirty.pending_questions or dirty.all then
|
|
noctalia.state.set("opencode.pending_questions", pending_questions)
|
|
end
|
|
if dirty.unread_count or dirty.all then
|
|
noctalia.state.set("opencode.unread_count", unread_count)
|
|
end
|
|
if dirty.last_error or dirty.all then
|
|
noctalia.state.set("opencode.last_error", last_error)
|
|
end
|
|
if dirty.selection or dirty.all then
|
|
-- Display value: show the server default until the user overrides it.
|
|
noctalia.state.set("opencode.selected_model", selected_model or default_model_display)
|
|
noctalia.state.set("opencode.selected_agent", selected_agent)
|
|
noctalia.state.set("opencode.model_options", model_options)
|
|
noctalia.state.set("opencode.agent_options", agent_options)
|
|
end
|
|
dirty = {}
|
|
end
|
|
|
|
local function set_connection_status(status, err)
|
|
connection.status = status
|
|
connection.error = err
|
|
if status == "busy" then
|
|
busy_since = os.time()
|
|
else
|
|
busy_since = nil
|
|
end
|
|
mark_dirty("connection")
|
|
publish()
|
|
end
|
|
|
|
local function set_last_error(message, detail)
|
|
last_error = { message = message, detail = detail }
|
|
mark_dirty("last_error")
|
|
publish()
|
|
end
|
|
|
|
local function clear_last_error()
|
|
last_error = nil
|
|
mark_dirty("last_error")
|
|
publish()
|
|
end
|
|
|
|
local function find_opencode_on_path()
|
|
if noctalia.commandExists("opencode") then
|
|
return "opencode"
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function start_managed_server()
|
|
local host = get_server_host()
|
|
local port = get_server_port()
|
|
|
|
-- Check health first — server may already be running
|
|
API.health(function(resp)
|
|
if resp.ok and resp.status == 200 then
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if ok and data.healthy then
|
|
connection.server_version = data.version
|
|
managed_port = port
|
|
set_connection_status("online")
|
|
debug_log("Connected to existing server at " .. host .. ":" .. port)
|
|
load_initial_data()
|
|
return
|
|
end
|
|
end
|
|
|
|
-- No healthy server — start one
|
|
local exe = find_opencode_on_path()
|
|
if not exe then
|
|
set_connection_status("offline", tr("error.exe_not_found"))
|
|
set_last_error(tr("error.exe_not_found_detail"))
|
|
return
|
|
end
|
|
|
|
local workspace = get_default_workspace()
|
|
local cmd = "opencode serve --hostname " .. shq(host) .. " --port " .. tostring(port)
|
|
if workspace ~= "" then
|
|
cmd = "cd " .. shq(workspace) .. " && " .. cmd
|
|
end
|
|
|
|
debug_log("Starting managed server: " .. cmd)
|
|
set_connection_status("starting")
|
|
|
|
noctalia.runAsync(cmd, function(result)
|
|
-- Server should not exit immediately; if it did, there's an error
|
|
if result.exitCode ~= 0 then
|
|
local err = (result.stderr ~= "" and result.stderr or result.stdout or "unknown")
|
|
err = err:gsub("[\r\n]", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
|
set_connection_status("offline", err)
|
|
set_last_error(tr("error.server_failed"), err)
|
|
end
|
|
end)
|
|
|
|
-- Retry health check with backoff
|
|
local attempts = 0
|
|
local function retry_health()
|
|
attempts = attempts + 1
|
|
if attempts > 10 then
|
|
set_connection_status("offline", tr("error.server_timeout"))
|
|
set_last_error(tr("error.server_timeout_detail"))
|
|
return
|
|
end
|
|
local delay = math.min(1000 * (2 ^ attempts), 16000)
|
|
-- Use a simple timer pattern: schedule next check
|
|
-- Since we don't have real timers in service, poll via state change
|
|
-- For now, try health immediately with increasing waits
|
|
API.health(function(resp2)
|
|
if resp2.ok and resp2.status == 200 then
|
|
local ok2, data2 = pcall(noctalia.json.decode, resp2.body)
|
|
if ok2 and data2.healthy then
|
|
connection.server_version = data2.version
|
|
managed_port = port
|
|
managed_pid = true -- we spawned it
|
|
set_connection_status("online")
|
|
debug_log("Server started successfully")
|
|
load_initial_data()
|
|
return
|
|
end
|
|
end
|
|
-- Schedule retry
|
|
local cmd2 = "sleep " .. tostring(delay / 1000) .. " && echo done"
|
|
noctalia.runAsync(cmd2, function(_)
|
|
retry_health()
|
|
end)
|
|
end)
|
|
end
|
|
|
|
-- Start retry chain after a short initial delay
|
|
noctalia.runAsync("sleep 1 && echo done", function(_)
|
|
retry_health()
|
|
end)
|
|
end)
|
|
end
|
|
|
|
local function connect_external()
|
|
local url = get_server_url()
|
|
if url == "" then
|
|
set_connection_status("offline", tr("error.external_not_configured"))
|
|
return
|
|
end
|
|
|
|
set_connection_status("starting")
|
|
debug_log("Connecting to external server: " .. url)
|
|
|
|
API.health(function(resp)
|
|
if resp.ok and resp.status == 200 then
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if ok and data.healthy then
|
|
connection.server_version = data.version
|
|
set_connection_status("online")
|
|
debug_log("Connected to external server")
|
|
load_initial_data()
|
|
return
|
|
end
|
|
end
|
|
local err = tr("error.connection_failed") .. " (HTTP " .. tostring(resp.status) .. ")"
|
|
set_connection_status("offline", err)
|
|
set_last_error(tr("error.connection_failed_detail"), resp.body)
|
|
end)
|
|
end
|
|
|
|
local function connect()
|
|
clear_last_error()
|
|
sse_reconnect_attempts = 0
|
|
local mode = get_server_mode()
|
|
if mode == "external" then
|
|
connect_external()
|
|
else
|
|
start_managed_server()
|
|
end
|
|
end
|
|
|
|
-- ── data loading ─────────────────────────────────────────────────────────────
|
|
|
|
local function load_sessions()
|
|
-- Cap the session list: decoding the full history (can be 90+ sessions /
|
|
-- tens of KB) in one async callback blows the CPU budget. The chooser only
|
|
-- needs the most recent sessions.
|
|
API.get("/session?limit=40", function(resp)
|
|
if not (resp.ok and resp.status == 200) then
|
|
debug_log("Failed to load sessions: " .. tostring(resp.status))
|
|
return
|
|
end
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if not ok or type(data) ~= "table" then
|
|
debug_log("Failed to parse sessions response")
|
|
return
|
|
end
|
|
-- Sort by updated time descending
|
|
table.sort(data, function(a, b)
|
|
local ta = (a.time and a.time.updated) or 0
|
|
local tb = (b.time and b.time.updated) or 0
|
|
return ta > tb
|
|
end)
|
|
-- Defensive dedup by id (a session must never appear twice).
|
|
local seen = {}
|
|
local deduped = {}
|
|
for _, s in ipairs(data) do
|
|
if type(s) == "table" and s.id and not seen[s.id] then
|
|
seen[s.id] = true
|
|
deduped[#deduped + 1] = s
|
|
end
|
|
end
|
|
sessions = deduped
|
|
mark_dirty("sessions")
|
|
publish()
|
|
-- Try to restore active session
|
|
restore_active_session()
|
|
end)
|
|
end
|
|
|
|
-- Restore the previously active session if it belongs to the same boot.
|
|
restore_active_session = function()
|
|
if restore_done then return end
|
|
restore_done = true
|
|
local id = restore_session_if_same_boot()
|
|
if id then
|
|
select_session(id)
|
|
end
|
|
end
|
|
|
|
local function load_messages(session_id, limit)
|
|
if not safe_id(session_id) then return end
|
|
local lim = limit or get_max_messages()
|
|
local path = "/session/" .. session_id .. "/message?limit=" .. tostring(lim)
|
|
API.get(path, function(resp)
|
|
if not (resp.ok and resp.status == 200) then
|
|
debug_log("Failed to load messages: " .. tostring(resp.status))
|
|
return
|
|
end
|
|
-- Nothing changed since last load: skip the decode + reconcile entirely.
|
|
if resp.body == last_messages_body and not optimistic_msg then
|
|
return
|
|
end
|
|
last_messages_body = resp.body
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if not ok or type(data) ~= "table" then
|
|
debug_log("Failed to parse messages response")
|
|
return
|
|
end
|
|
messages = data
|
|
-- Re-append the optimistic user message if the server hasn't echoed it
|
|
-- yet, so the just-sent message doesn't flicker out before confirmation.
|
|
--
|
|
-- The server injects extra parts (e.g. a <memory_context> block) into the
|
|
-- real user message, so the sent text is usually parts[2..], not parts[1].
|
|
-- Match by scanning ALL text parts of every user message for our text.
|
|
if optimistic_msg then
|
|
local want = optimistic_msg.parts[1].text
|
|
local echoed = false
|
|
for _, m in ipairs(messages) do
|
|
if type(m) == "table" and type(m.info) == "table" and m.info.role == "user"
|
|
and type(m.parts) == "table" then
|
|
for _, p in ipairs(m.parts) do
|
|
if type(p) == "table" and type(p.text) == "string"
|
|
and (p.text == want or p.text:find(want, 1, true)) then
|
|
echoed = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if echoed then break end
|
|
end
|
|
if not echoed then
|
|
messages[#messages + 1] = optimistic_msg
|
|
else
|
|
optimistic_msg = nil
|
|
end
|
|
end
|
|
-- Defensive dedup by message id (a message must never render twice).
|
|
local seen = {}
|
|
local deduped = {}
|
|
for _, m in ipairs(messages) do
|
|
local mid = (type(m) == "table" and type(m.info) == "table" and m.info.id) or nil
|
|
if not mid or not seen[mid] then
|
|
if mid then seen[mid] = true end
|
|
deduped[#deduped + 1] = m
|
|
end
|
|
end
|
|
messages = deduped
|
|
mark_dirty("messages")
|
|
publish()
|
|
end)
|
|
end
|
|
|
|
local function load_mcp_status()
|
|
API.get("/mcp", function(resp)
|
|
if not (resp.ok and resp.status == 200) then
|
|
debug_log("Failed to load MCP status")
|
|
return
|
|
end
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if not ok or type(data) ~= "table" then
|
|
debug_log("Failed to parse MCP response")
|
|
return
|
|
end
|
|
mcp_status = data
|
|
mark_dirty("mcp_status")
|
|
publish()
|
|
end)
|
|
end
|
|
|
|
local function load_providers()
|
|
API.get("/config/providers", function(resp)
|
|
if not (resp.ok and resp.status == 200) then
|
|
debug_log("Failed to load providers")
|
|
return
|
|
end
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if not ok or type(data) ~= "table" then
|
|
debug_log("Failed to parse providers response")
|
|
return
|
|
end
|
|
-- Flatten providers into a simple list, and build a flat model-option
|
|
-- list the panel's select can consume: { value = "prov/model", label }.
|
|
-- NOTE: /config/providers returns a LIST of provider objects, each with
|
|
-- its own .id. Iterate with ipairs and use the object's real id (not the
|
|
-- array index) — using the index produced values like "1/Code" that the
|
|
-- server can't resolve ("Model not found: Code").
|
|
local list = {}
|
|
local opts = {}
|
|
if type(data.providers) == "table" then
|
|
for _, p in ipairs(data.providers) do
|
|
if type(p) == "table" and type(p.id) == "string" and p.id ~= "" then
|
|
local pid = p.id
|
|
table.insert(list, p)
|
|
if type(p.models) == "table" then
|
|
for model_id, _ in pairs(p.models) do
|
|
opts[#opts + 1] = {
|
|
value = pid .. "/" .. model_id,
|
|
label = model_id .. " (" .. pid .. ")",
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(opts, function(a, b) return a.label < b.label end)
|
|
providers = list
|
|
model_options = opts
|
|
-- Seed a DISPLAY-ONLY default for the picker (never auto-sent). Prefer
|
|
-- the configured default_model, else the first server default entry.
|
|
if not default_model_display then
|
|
local cfg = get_default_model()
|
|
if cfg ~= "" then
|
|
default_model_display = cfg
|
|
elseif type(data.default) == "table" then
|
|
for prov_id, model_id in pairs(data.default) do
|
|
default_model_display = prov_id .. "/" .. model_id
|
|
break
|
|
end
|
|
end
|
|
end
|
|
mark_dirty("providers")
|
|
mark_dirty("selection")
|
|
publish()
|
|
end)
|
|
end
|
|
|
|
local function load_agents()
|
|
API.get("/agent", function(resp)
|
|
if not (resp.ok and resp.status == 200) then
|
|
debug_log("Failed to load agents")
|
|
return
|
|
end
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if not ok or type(data) ~= "table" then
|
|
debug_log("Failed to parse agents response")
|
|
return
|
|
end
|
|
agents = data
|
|
-- Build flat agent-option list; only primary agents are user-selectable.
|
|
local opts = {}
|
|
if type(data) == "table" then
|
|
for _, a in ipairs(data) do
|
|
if type(a) == "table" and type(a.name) == "string" then
|
|
if a.mode == "primary" or a.mode == nil then
|
|
opts[#opts + 1] = { value = a.name, label = a.name }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(opts, function(x, y) return x.label < y.label end)
|
|
agent_options = opts
|
|
mark_dirty("agents")
|
|
publish()
|
|
end)
|
|
end
|
|
|
|
load_initial_data = function()
|
|
load_sessions()
|
|
load_mcp_status()
|
|
load_providers()
|
|
load_agents()
|
|
start_sse()
|
|
end
|
|
|
|
-- ── session management ───────────────────────────────────────────────────────
|
|
|
|
local function set_active_session(session)
|
|
active_session = session
|
|
messages = {}
|
|
last_messages_body = nil -- force a fresh decode for the new session
|
|
if not session then
|
|
optimistic_msg = nil
|
|
end
|
|
mark_dirty("active_session")
|
|
mark_dirty("messages")
|
|
if session and session.id then
|
|
load_messages(session.id)
|
|
persist_active_session()
|
|
end
|
|
publish()
|
|
end
|
|
|
|
select_session = function(session_id)
|
|
if not safe_id(session_id) then return end
|
|
-- Find session in list
|
|
for _, s in ipairs(sessions) do
|
|
if s.id == session_id then
|
|
set_active_session(s)
|
|
return
|
|
end
|
|
end
|
|
-- Not in list — fetch it
|
|
API.get("/session/" .. session_id, function(resp)
|
|
if resp.ok and resp.status == 200 then
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if ok and type(data) == "table" then
|
|
set_active_session(data)
|
|
return
|
|
end
|
|
end
|
|
-- Session no longer exists
|
|
set_last_error(tr("error.session_not_found"), session_id)
|
|
active_session = nil
|
|
mark_dirty("active_session")
|
|
publish()
|
|
end)
|
|
end
|
|
|
|
local function create_session(title)
|
|
local body = {}
|
|
if type(title) == "string" and title ~= "" then
|
|
body.title = title
|
|
end
|
|
API.post("/session", body, function(resp)
|
|
if resp.ok and resp.status == 200 then
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if ok and type(data) == "table" then
|
|
set_active_session(data)
|
|
load_sessions() -- refresh list
|
|
clear_last_error()
|
|
return
|
|
end
|
|
end
|
|
set_last_error(tr("error.create_session_failed"), resp.body)
|
|
end)
|
|
end
|
|
|
|
local function delete_session(session_id)
|
|
if not safe_id(session_id) then return end
|
|
API.delete("/session/" .. session_id, function(resp)
|
|
if resp.ok then
|
|
if active_session and active_session.id == session_id then
|
|
active_session = nil
|
|
messages = {}
|
|
mark_dirty("active_session")
|
|
mark_dirty("messages")
|
|
persist_active_session()
|
|
end
|
|
load_sessions()
|
|
else
|
|
set_last_error(tr("error.delete_session_failed"), "HTTP " .. tostring(resp.status))
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function abort_session(session_id)
|
|
if not safe_id(session_id) then return end
|
|
API.post("/session/" .. session_id .. "/abort", {}, function(resp)
|
|
if not resp.ok then
|
|
set_last_error(tr("error.abort_failed"), "HTTP " .. tostring(resp.status))
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function rename_session(session_id, title)
|
|
if not safe_id(session_id) then return end
|
|
API.patch("/session/" .. session_id, { title = title }, function(resp)
|
|
if resp.ok and resp.status == 200 then
|
|
load_sessions()
|
|
-- Update active session if it's the one renamed
|
|
if active_session and active_session.id == session_id then
|
|
local ok, data = pcall(noctalia.json.decode, resp.body)
|
|
if ok then
|
|
active_session = data
|
|
mark_dirty("active_session")
|
|
publish()
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- ── messaging ────────────────────────────────────────────────────────────────
|
|
|
|
-- Only send a model override when the model actually exists in the provider
|
|
-- list the server reported. A stale/unknown override (e.g. a configured
|
|
-- `default_model` pointing at a provider/model the server can't run) otherwise
|
|
-- gets sent on every turn, failing the session repeatedly — while the CLI, which
|
|
-- sends no override, works fine. When unknown, fall back to no override so the
|
|
-- server uses the session's own default.
|
|
local function model_is_known(model)
|
|
if type(model) ~= "string" or model == "" then return false end
|
|
for _, o in ipairs(model_options) do
|
|
if o.value == model then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function send_prompt(session_id, text, model, agent)
|
|
if not safe_id(session_id) then return end
|
|
if type(text) ~= "string" or text == "" then return end
|
|
|
|
-- Optimistically append the user message so it appears immediately on the
|
|
-- right. It is replaced by the server's copy once echoed (see load_messages).
|
|
local now_ms = os.time() * 1000
|
|
optimistic_msg = {
|
|
info = {
|
|
id = "local-" .. tostring(now_ms),
|
|
role = "user",
|
|
sessionID = session_id,
|
|
time = { created = now_ms },
|
|
},
|
|
parts = { { type = "text", text = text } },
|
|
}
|
|
messages[#messages + 1] = optimistic_msg
|
|
mark_dirty("messages")
|
|
publish()
|
|
|
|
-- Mark busy immediately so the thinking indicator shows right away.
|
|
set_connection_status("busy")
|
|
|
|
-- Build parts
|
|
local parts = { { type = "text", text = text } }
|
|
local body = { parts = parts }
|
|
|
|
if type(model) == "string" and model ~= "" then
|
|
-- Parse "provider/model" format
|
|
local provider_id, model_id = model:match("([^/]+)/(.+)")
|
|
if provider_id and model_id then
|
|
body.model = { providerID = provider_id, modelID = model_id }
|
|
end
|
|
end
|
|
if type(agent) == "string" and agent ~= "" then
|
|
body.agent = agent
|
|
end
|
|
|
|
-- Use prompt_async to not block
|
|
API.post("/session/" .. session_id .. "/prompt_async", body, function(resp)
|
|
if resp.ok then
|
|
clear_last_error()
|
|
else
|
|
-- The turn was rejected (busy session, bad model, server error…).
|
|
-- Recover: drop the optimistic bubble and re-enable the composer.
|
|
optimistic_msg = nil
|
|
mark_dirty("messages")
|
|
set_connection_status("online")
|
|
set_last_error(tr("error.prompt_failed"),
|
|
"HTTP " .. tostring(resp.status) .. (resp.body and (": " .. tostring(resp.body)) or ""))
|
|
publish()
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- ── permissions ──────────────────────────────────────────────────────────────
|
|
|
|
local function respond_to_permission(session_id, permission_id, response, remember)
|
|
-- The panel sends response = "allow" | "allow:true" | "deny". Map those to
|
|
-- the server's reply enum: once / always / reject.
|
|
if not safe_id(permission_id) then return end
|
|
local reply = "reject"
|
|
if response == "allow" then
|
|
reply = remember and "always" or "once"
|
|
end
|
|
local body = { reply = reply }
|
|
API.post("/permission/" .. permission_id .. "/reply", body, function(resp)
|
|
if resp.ok then
|
|
-- Remove from pending
|
|
for i, p in ipairs(pending_permissions) do
|
|
if p.permissionID == permission_id then
|
|
table.remove(pending_permissions, i)
|
|
break
|
|
end
|
|
end
|
|
mark_dirty("pending_permissions")
|
|
set_connection_status("online")
|
|
publish()
|
|
else
|
|
set_last_error(tr("error.permission_failed"), "HTTP " .. tostring(resp.status))
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function respond_to_question(request_id, answers)
|
|
-- answers: array of arrays of selected option labels (one inner array per
|
|
-- question, in order). The request body must be a JSON array of arrays.
|
|
if not safe_id(request_id) then return end
|
|
if type(answers) ~= "table" then return end
|
|
local body = { answers = answers }
|
|
API.post("/question/" .. request_id .. "/reply", body, function(resp)
|
|
if resp.ok then
|
|
for i, q in ipairs(pending_questions) do
|
|
if q.requestID == request_id then
|
|
table.remove(pending_questions, i)
|
|
break
|
|
end
|
|
end
|
|
mark_dirty("pending_questions")
|
|
set_connection_status("online")
|
|
publish()
|
|
else
|
|
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function reject_question(request_id)
|
|
if not safe_id(request_id) then return end
|
|
API.post("/question/" .. request_id .. "/reject", {}, function(resp)
|
|
if resp.ok then
|
|
for i, q in ipairs(pending_questions) do
|
|
if q.requestID == request_id then
|
|
table.remove(pending_questions, i)
|
|
break
|
|
end
|
|
end
|
|
mark_dirty("pending_questions")
|
|
set_connection_status("online")
|
|
publish()
|
|
else
|
|
set_last_error(tr("error.question_failed"), "HTTP " .. tostring(resp.status))
|
|
end
|
|
end)
|
|
end
|
|
|
|
-- ── SSE event stream ────────────────────────────────────────────────────────
|
|
|
|
local SSE = {}
|
|
|
|
-- Throttle for message reloads during streaming (os.clock gives fractional seconds)
|
|
last_messages_reload = 0
|
|
RELOAD_THROTTLE_S = 0.3
|
|
|
|
-- Dedup set for unread counting (prevents inflating count on every part update)
|
|
local counted_messages = {}
|
|
local COUNTED_MAX = 100
|
|
|
|
-- Parse SSE event data
|
|
local function parse_sse_line(line)
|
|
if line == nil then return nil, nil end
|
|
if line == "" then return nil, nil end
|
|
if line:sub(1, 6) == "data: " then
|
|
local json_str = line:sub(7)
|
|
local ok, data = pcall(noctalia.json.decode, json_str)
|
|
if ok and type(data) == "table" then
|
|
return data.type, data
|
|
end
|
|
end
|
|
return nil, nil
|
|
end
|
|
|
|
function SSE.start()
|
|
if sse_stream then return end -- already connected
|
|
local mode = get_server_mode()
|
|
local base_url
|
|
if mode == "external" then
|
|
base_url = get_server_url()
|
|
if base_url == "" then return end
|
|
else
|
|
base_url = "http://" .. get_server_host() .. ":" .. tostring(get_server_port())
|
|
end
|
|
|
|
local url = base_url .. "/event"
|
|
local request = {
|
|
url = url,
|
|
method = "GET",
|
|
headers = { "Accept: text/event-stream" },
|
|
}
|
|
|
|
local buffer = ""
|
|
|
|
sse_stream = noctalia.httpStream(request,
|
|
-- onProgress: each line
|
|
function(line)
|
|
buffer = buffer .. line .. "\n"
|
|
-- Process complete events (double newline)
|
|
while true do
|
|
local event_end = buffer:find("\n\n")
|
|
if not event_end then break end
|
|
local event_data = buffer:sub(1, event_end)
|
|
buffer = buffer:sub(event_end + 2)
|
|
|
|
-- Extract data lines
|
|
for data_line in event_data:gmatch("([^\r\n]+)") do
|
|
local event_type, event = parse_sse_line(data_line)
|
|
if event_type then
|
|
SSE.handle_event(event_type, event)
|
|
end
|
|
end
|
|
end
|
|
end,
|
|
-- onFinish
|
|
function(result)
|
|
sse_stream = nil
|
|
sse_reconnect_attempts = sse_reconnect_attempts + 1
|
|
if sse_reconnect_attempts <= sse_max_reconnect then
|
|
-- Reconnect with backoff
|
|
local delay = math.min(1000 * (2 ^ sse_reconnect_attempts), 30000)
|
|
-- Schedule reconnect via a sleep command
|
|
noctalia.runAsync("sleep " .. tostring(delay / 1000) .. " && echo done", function(_)
|
|
SSE.start()
|
|
end)
|
|
else
|
|
set_last_error(tr("error.sse_disconnected"))
|
|
end
|
|
end
|
|
)
|
|
end
|
|
|
|
function SSE.stop()
|
|
-- The stream will close when the server closes it; we just clear our ref
|
|
sse_stream = nil
|
|
end
|
|
|
|
function SSE.handle_event(event_type, event)
|
|
debug_log("SSE event: " .. tostring(event_type))
|
|
|
|
if event_type == "server.connected" or event_type == "server.heartbeat" then
|
|
-- Connection lifecycle event; heartbeat is a keepalive ping — no-op.
|
|
return
|
|
end
|
|
|
|
if event_type == "message.part.updated" or event_type == "message.updated" then
|
|
local session_id = event.sessionID or (event.properties and event.properties.sessionID)
|
|
local props = event.properties or event
|
|
-- Throttle message reloads during streaming
|
|
if active_session and session_id == active_session.id then
|
|
local now = os.clock()
|
|
if now - last_messages_reload > RELOAD_THROTTLE_S then
|
|
last_messages_reload = now
|
|
load_messages(active_session.id)
|
|
end
|
|
end
|
|
-- Track unread once per message (dedup by message ID)
|
|
if props and props.role == "assistant" then
|
|
local msg_id = props.messageID or props.id
|
|
if msg_id and not counted_messages[msg_id] then
|
|
counted_messages[msg_id] = true
|
|
unread_count = unread_count + 1
|
|
mark_dirty("unread_count")
|
|
publish()
|
|
-- Prevent unbounded growth
|
|
local n = 0
|
|
for _ in pairs(counted_messages) do n = n + 1 end
|
|
if n > COUNTED_MAX then
|
|
counted_messages = {}
|
|
end
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
if event_type == "session.status" then
|
|
local session_id = event.sessionID or (event.properties and event.properties.sessionID)
|
|
local status = event.status or (event.properties and event.properties.status)
|
|
-- status arrives as a table like { type = "busy" }; normalize to the string.
|
|
local st = type(status) == "table" and status.type or status
|
|
if session_id and st then
|
|
session_status[session_id] = st
|
|
mark_dirty("session_status")
|
|
-- Update connection status based on session activity
|
|
if active_session and active_session.id == session_id then
|
|
if st == "processing" then
|
|
set_connection_status("busy")
|
|
elseif st == "idle" then
|
|
set_connection_status("online")
|
|
unread_count = 0
|
|
mark_dirty("unread_count")
|
|
elseif st == "error" then
|
|
set_connection_status("online")
|
|
end
|
|
end
|
|
publish()
|
|
end
|
|
return
|
|
end
|
|
|
|
if event_type == "session.idle" then
|
|
local session_id = event.sessionID or (event.properties and event.properties.sessionID)
|
|
if session_id then
|
|
session_status[session_id] = "idle"
|
|
mark_dirty("session_status")
|
|
if active_session and active_session.id == session_id then
|
|
set_connection_status("online")
|
|
unread_count = 0
|
|
mark_dirty("unread_count")
|
|
counted_messages = {}
|
|
load_messages(session_id)
|
|
end
|
|
publish()
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Session-level error: the server aborts the turn. Recover the UI so the
|
|
-- composer is usable again, surface the real error, and drop the optimistic
|
|
-- user bubble (the server did not accept the turn).
|
|
if event_type == "session.error" then
|
|
local props = event.properties or event.data or event
|
|
local session_id = props and props.sessionID
|
|
local err = props and props.error
|
|
local name = (type(err) == "table" and err.name) or "UnknownError"
|
|
local detail = (type(err) == "table" and type(err.data) == "table" and err.data.message)
|
|
or tr("error.session_error_detail")
|
|
session_status[session_id or ""] = "error"
|
|
mark_dirty("session_status")
|
|
if not active_session or not session_id or active_session.id == session_id then
|
|
-- MessageAbortedError is expected when the user hits Stop; don't shout.
|
|
if name ~= "MessageAbortedError" then
|
|
set_last_error(tr("error.session_error"), tostring(detail))
|
|
end
|
|
optimistic_msg = nil
|
|
set_connection_status("online")
|
|
unread_count = 0
|
|
mark_dirty("unread_count")
|
|
if session_id then load_messages(session_id) end
|
|
end
|
|
publish()
|
|
return
|
|
end
|
|
|
|
-- Permission events: the agent asks the user to approve a tool action.
|
|
-- Both `permission.asked` (v1) and `permission.v2.asked` (v2) carry the
|
|
-- request id in `properties.id` (^per) — NOT `permissionID`. Normalize that
|
|
-- into `permissionID` for the panel's permission card.
|
|
if event_type == "permission.asked" or event_type == "permission.v2.asked" then
|
|
local props = event.properties or event
|
|
if props and props.id and props.sessionID then
|
|
local perm = {
|
|
permissionID = props.id,
|
|
sessionID = props.sessionID,
|
|
permission = props.permission,
|
|
patterns = props.patterns,
|
|
action = props.action,
|
|
resources = props.resources,
|
|
save = props.save,
|
|
metadata = props.metadata,
|
|
tool = props.tool,
|
|
}
|
|
-- Reuse an existing pending card for the same id instead of duplicating.
|
|
local found = false
|
|
for _, p in ipairs(pending_permissions) do
|
|
if p.permissionID == props.id then
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
if not found then
|
|
table.insert(pending_permissions, perm)
|
|
end
|
|
mark_dirty("pending_permissions")
|
|
set_connection_status("waiting_permission")
|
|
publish()
|
|
noctalia.notify(tr("notify.permission_title"), tr("notify.permission_body"))
|
|
end
|
|
return
|
|
end
|
|
|
|
-- The permission was answered (here or on another surface); drop the card.
|
|
if event_type == "permission.replied" or event_type == "permission.v2.replied" then
|
|
local props = event.properties or event
|
|
local rid = props and props.requestID
|
|
if rid then
|
|
for i, p in ipairs(pending_permissions) do
|
|
if p.permissionID == rid then
|
|
table.remove(pending_permissions, i)
|
|
break
|
|
end
|
|
end
|
|
mark_dirty("pending_permissions")
|
|
set_connection_status("online")
|
|
publish()
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Question/choice events: the agent asks the user to pick among options.
|
|
if event_type == "question.asked" then
|
|
local props = event.properties or event
|
|
if props and props.sessionID and type(props.questions) == "table" and #props.questions > 0 then
|
|
local req = {
|
|
requestID = props.id or props.requestID,
|
|
sessionID = props.sessionID,
|
|
questions = props.questions,
|
|
tool = props.tool,
|
|
}
|
|
-- Reuse an existing pending request for the same id instead of duplicating.
|
|
local found = false
|
|
for _, q in ipairs(pending_questions) do
|
|
if q.requestID == req.requestID then
|
|
q.questions = req.questions
|
|
found = true
|
|
break
|
|
end
|
|
end
|
|
if not found then
|
|
table.insert(pending_questions, req)
|
|
end
|
|
mark_dirty("pending_questions")
|
|
set_connection_status("waiting_permission")
|
|
publish()
|
|
end
|
|
return
|
|
end
|
|
|
|
-- The agent already got an answer (from another surface); drop the prompt.
|
|
if event_type == "question.replied" or event_type == "question.rejected" then
|
|
local props = event.properties or event
|
|
local rid = props and props.requestID
|
|
if rid then
|
|
for i, q in ipairs(pending_questions) do
|
|
if q.requestID == rid then
|
|
table.remove(pending_questions, i)
|
|
break
|
|
end
|
|
end
|
|
mark_dirty("pending_questions")
|
|
set_connection_status("online")
|
|
publish()
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Unknown event — log if debug
|
|
debug_log("Unknown event: " .. tostring(event_type))
|
|
end
|
|
|
|
function start_sse()
|
|
SSE.start()
|
|
end
|
|
|
|
function stop_sse()
|
|
SSE.stop()
|
|
end
|
|
|
|
-- ── ipc handling ─────────────────────────────────────────────────────────────
|
|
|
|
function onIpc(event, payload)
|
|
debug_log("IPC: " .. tostring(event))
|
|
|
|
-- Self-heal a stale busy status: if we've been "busy" longer than the
|
|
-- threshold with no idle/error event, the SSE update was likely dropped.
|
|
-- Clear it so the composer unlocks and sending works again.
|
|
if connection.status == "busy" and busy_since and (os.time() - busy_since) > BUSY_STALE_S then
|
|
optimistic_msg = nil
|
|
mark_dirty("messages")
|
|
set_connection_status("online")
|
|
if active_session then load_messages(active_session.id) end
|
|
end
|
|
|
|
if event == "select_session" then
|
|
select_session(payload)
|
|
elseif event == "deselect_session" then
|
|
set_active_session(nil)
|
|
elseif event == "create_session" then
|
|
create_session(payload)
|
|
elseif event == "delete_session" then
|
|
if type(payload) == "string" then
|
|
delete_session(payload)
|
|
end
|
|
elseif event == "abort_session" then
|
|
if active_session then
|
|
abort_session(active_session.id)
|
|
end
|
|
elseif event == "send_prompt" then
|
|
if active_session then
|
|
local model = ""
|
|
if selected_model and selected_model ~= "" then
|
|
-- An explicit pick comes from the picker (always known); guard anyway.
|
|
if model_is_known(selected_model) then
|
|
model = selected_model
|
|
end
|
|
else
|
|
-- Config default only if the server actually reports that model.
|
|
-- Warn when the configured default is bogus so the user knows to
|
|
-- fix the setting instead of silently running a different model.
|
|
local dm = get_default_model()
|
|
if model_is_known(dm) then
|
|
model = dm
|
|
elseif dm ~= "" then
|
|
noctalia.log("opencode-companion: default_model " .. tostring(dm) .. " not available; sending without an override")
|
|
end
|
|
end
|
|
local agent = (selected_agent and selected_agent ~= "") and selected_agent or get_default_agent()
|
|
send_prompt(active_session.id, payload, model, agent)
|
|
end
|
|
elseif event == "set_model" then
|
|
if type(payload) == "string" and payload ~= "" then
|
|
selected_model = payload
|
|
mark_dirty("selection")
|
|
publish()
|
|
end
|
|
elseif event == "set_agent" then
|
|
if type(payload) == "string" and payload ~= "" then
|
|
selected_agent = payload
|
|
mark_dirty("selection")
|
|
publish()
|
|
end
|
|
elseif event == "permission_response" then
|
|
-- payload: "permission_id:response[:remember]"
|
|
if type(payload) == "string" and active_session then
|
|
local perm_id, response, remember = payload:match("^([^:]+):([^:]+):?(%w*)")
|
|
if perm_id and response then
|
|
local rem = (remember == "true")
|
|
respond_to_permission(active_session.id, perm_id, response, rem)
|
|
end
|
|
end
|
|
elseif event == "question_reply" then
|
|
-- payload: "requestID\2answers" where answers is a JSON string of the
|
|
-- outer array; the panel encodes it as JSON for safe transport.
|
|
if type(payload) == "string" and active_session then
|
|
local rid, answers_json = payload:match("^([^\2]+)\2(.+)")
|
|
if rid and answers_json then
|
|
local ok, answers = pcall(noctalia.json.decode, answers_json)
|
|
if ok and type(answers) == "table" then
|
|
respond_to_question(rid, answers)
|
|
end
|
|
end
|
|
end
|
|
elseif event == "question_reject" then
|
|
if type(payload) == "string" and active_session then
|
|
reject_question(payload)
|
|
end
|
|
elseif event == "refresh" then
|
|
load_initial_data()
|
|
elseif event == "reconnect" then
|
|
connect()
|
|
elseif event == "clear_unread" then
|
|
unread_count = 0
|
|
mark_dirty("unread_count")
|
|
publish()
|
|
elseif event == "clear_error" then
|
|
last_error = nil
|
|
mark_dirty("last_error")
|
|
publish()
|
|
elseif event == "rename_session" then
|
|
if type(payload) == "string" then
|
|
local id, title = payload:match("^([^:]+):(.+)")
|
|
if id and title then
|
|
rename_session(id, title)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── init ─────────────────────────────────────────────────────────────────────
|
|
|
|
function onOpen()
|
|
boot_id = read_boot_id()
|
|
-- Build state file path in plugin data dir
|
|
local plugin_dir = noctalia.pluginDataDir and noctalia.pluginDataDir()
|
|
if plugin_dir then
|
|
state_file = plugin_dir .. "/opencode_state.json"
|
|
end
|
|
|
|
-- Initialize state
|
|
mark_dirty("all")
|
|
publish()
|
|
|
|
-- Attempt connection (skip if the user disabled auto_start; they can
|
|
-- still reconnect manually via the panel's refresh/reconnect action).
|
|
if get_auto_start() then
|
|
connect()
|
|
end
|
|
end
|
|
|
|
function onExit()
|
|
SSE.stop()
|
|
managed_pid = nil
|
|
sse_stream = nil
|
|
end
|
|
|
|
-- Start on load
|
|
onOpen()
|