From 9e9eb15555d9a657b36ee6ae9ef208c0049588f5 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 30 Oct 2026 15:02:39 -0600 Subject: initial commit --- LICENSE | 18 ++++ README.md | 84 ++++++++++++++++++ _selfcheck.lua | 161 +++++++++++++++++++++++++++++++++++ mise.toml | 4 + panto-report-status-0.1.0-1.rockspec | 27 ++++++ panto-report-status/init.lua | 125 +++++++++++++++++++++++++++ 6 files changed, 419 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 _selfcheck.lua create mode 100644 mise.toml create mode 100644 panto-report-status-0.1.0-1.rockspec create mode 100644 panto-report-status/init.lua diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..876f03c --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Travis Parker + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4849d0d --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# panto-report-status + +Reports panto's lifecycle through **OSC 7501**, the terminal-native +[Program Status Protocol](https://www.superlogical.com/rex/docs/build/program-status). +For the native Herdr app / herdr-core, Rex, Orca, and other supporting terminals. +No Herdr commands, sockets, hooks, or terminal-specific environment variables. + +For the **classic Herdr TUI**, use +[panto-herdr](https://code.tjp.lol/public/panto-herdr) instead. Choose one reporter +for your host; this extension does not publish Herdr-specific session metadata. + +## Install + +Use this checkout in your panto configuration: + +```toml +[extensions] +paths = ["/absolute/path/to/panto-report-status"] +``` + +Once the rock is published, `rocks = ["panto-report-status"]` is the alternative; +run `panto update` and start a new session. The extension's policy name is +`report-status`. + +## Reporting + +Uses the same lifecycle events as panto-herdr, with OSC's explicit completion +states rather than relying on Herdr to infer completion from `idle`. + +| panto event | OSC state | +| --- | --- | +| `session_ready` | `idle` | +| `turn_start`, `agent_submission`, `render_thinking` | `working`, message `thinking` | +| `render_tool_details`, `render_assistant_text` | `working`, message `tool:` or `responding` | +| `command_start` | `working`, message `compacting`, `shell`, or `cmd:` | +| `render_compaction` | refines already-active foreground work; never starts work | +| `turn_end` | `done`, `error` for `reason=failed`, or `idle` for interruption/exit request | +| `command_end` | `done`; this event does not carry a success/failure result | +| `background_work_start` / `background_work_end` | remains `working` until foreground work and all background work end | +| `session_end` | `clear`; later work callbacks cannot revive the report | + +Foreground completion is retained while background work finishes. Reports use +the root record with `app=panto`; messages are short, control-free ASCII labels, +base64-encoded on the wire. Duplicate reports are suppressed. A `done` or `error` +report is not immediately replaced with `idle`; the host decides how to present +unseen results, notifications, and sounds. Normal session exit clears the report. + +Writes synchronously to stdout only while it is the same terminal detected at +activation. Pipes and files receive no escape sequences. Only the outermost +panto in a terminal reports: `PANTO_REPORT_STATUS_OWNER` identifies the terminal +and owning PID, surviving `/new` while silencing nested panto processes in that +terminal. A panto in a different terminal can report normally. + +No capability query is needed: the protocol permits reports without a handshake. +Unsupported terminals ignore the sequences. An intervening multiplexer can +consume or discard them; run directly in the supporting host's pane. + +**Limitations:** no generic panto approval/question lifecycle event is available, +so this extension cannot reliably report `blocked`. It does not guess from text. +Background-work events have no individual task identity or outcome, so they use +one counter rather than child records or percentages. OSC does not publish a +transcript path or conversation-resume command. Write errors go through panto's +normal event-error handling; there is no protocol-level acknowledgement. + +## Compatibility + +- [Native Herdr / herdr-core](https://herdr.dev/core/) consumes OSC 7501 status. +- [Rex](https://www.superlogical.com/rex/docs/build/program-status#in-rex) uses it + for session/tab indicators and Lua events. +- [Orca v1.4.245](https://github.com/stablyai/orca/blob/v1.4.245/src/main/agent-hooks/README.md) + supports OSC 7501 for programs without managed integrations. Hooks take + precedence when present; OSC supplies status, not full native-chat/resume data. + +These are documented host capabilities, not a claim of live panto testing in +all three applications. + +## Check + +```sh +panto lua _selfcheck.lua +# Or: lua _selfcheck.lua +``` + +The check stubs panto and luv; it never emits to a live terminal. diff --git a/_selfcheck.lua b/_selfcheck.lua new file mode 100644 index 0000000..67a84c7 --- /dev/null +++ b/_selfcheck.lua @@ -0,0 +1,161 @@ +-- Run with `lua _selfcheck.lua` or `panto lua _selfcheck.lua`; no live terminal writes. +local events, writes, env = {}, {}, {} +local handle, inode, pid = "tty", 10, 100.0 +local write_error, partial_limit +os.getenv = function(name) return env[name] end +package.loaded.panto = nil +package.preload.panto = function() + return { ext = { on = function(name, handler) events[name] = handler end } } +end +package.loaded.luv = nil +package.preload.luv = function() + return { + guess_handle = function(fd) assert(fd == 1); return handle end, + fs_fstat = function(fd) assert(fd == 1); return { dev = 1, ino = inode, rdev = 2 } end, + os_getpid = function() return pid end, + os_setenv = function(name, value) env[name] = value end, + fs_write = function(fd, data, offset) + assert(fd == 1 and offset == -1) + if write_error then return nil, "EIO" end + local chunk = data:sub(1, partial_limit or #data) + writes[#writes + 1] = chunk + return #chunk + end, + } +end +local entry = assert(loadfile("panto-report-status/init.lua"))() +local function activate() + events = {} + entry.activate() +end +local function latest(state, message) + local last + for body in table.concat(writes):gmatch("\27%]7501;([^\27]*)\27\\") do last = body end + assert(last, "missing report") + assert(last == "state=" .. state .. ":app=panto" .. (message and ":msg=" .. message or ""), last) +end +local function unchanged(fn) + local count = #writes + fn() + assert(#writes == count) +end + +for _, kind in ipairs({ "pipe", "file", "unknown" }) do + handle = kind + activate() + assert(next(events) == nil and #writes == 0) +end +handle = "tty" +activate() +assert(env.PANTO_REPORT_STATUS_OWNER == "1:10:2@100") +local registered = 0 +for _ in pairs(events) do registered = registered + 1 end +assert(registered == 13) +events.session_ready() +latest("idle") +unchanged(events.session_ready) +unchanged(events.render_compaction) +unchanged(events.background_work_end) +unchanged(function() events.turn_end({ reason = "completed" }) end) + +for _, name in ipairs({ "turn_start", "agent_submission", "render_thinking" }) do + events[name]() + latest("working", "dGhpbmtpbmc=") -- thinking + unchanged(events[name]) + events.turn_end({ reason = "completed" }) + latest("done") +end +for _, item in ipairs({ + { "a", "dG9vbDph" }, { "ab", "dG9vbDphYg==" }, { "abc", "dG9vbDphYmM=" }, + { "a\27\n\t\127\194\128", "dG9vbDphPz8/Pz8/" }, +}) do + events.render_tool_details({ tool_name = item[1] }) + latest("working", item[2]) +end +events.render_assistant_text() +latest("working", "cmVzcG9uZGluZw==") -- responding +events.render_compaction() +latest("working", "Y29tcGFjdGluZw==") -- compacting +events.turn_end({ reason = "completed" }) +latest("done") +unchanged(events.render_compaction) + +for _, item in ipairs({ + { "compact", "Y29tcGFjdGluZw==" }, { "!", "c2hlbGw=" }, + { "!!", "c2hlbGw=" }, { "status", "Y21kOnN0YXR1cw==" }, +}) do + events.command_start({ command_name = item[1] }) + latest("working", item[2]) + events.command_end({ command_name = item[1] }) + latest("done") +end + +-- Foreground and background work must settle together, including failures. +events.turn_start() +events.background_work_start() +events.background_work_start() +events.turn_end({ reason = "failed" }) +latest("working", "YmFja2dyb3VuZA==") -- background +unchanged(events.background_work_end) +events.background_work_end() +latest("error") +unchanged(events.background_work_end) +events.turn_start() +events.turn_end({ reason = "completed" }) +latest("done") +for _, reason in ipairs({ "interrupted", "exit_requested" }) do + events.turn_start() + events.turn_end({ reason = reason }) + latest("idle") +end +events.background_work_start() +latest("working", "YmFja2dyb3VuZA==") +events.background_work_end() +latest("done") +events.background_work_start() +events.turn_start() +unchanged(events.background_work_end) +latest("working", "dGhpbmtpbmc=") +events.turn_end() +latest("done") + +-- An output redirected after activation must remain clean. +handle = "pipe" +unchanged(events.turn_start) +handle = "tty" +events.turn_start() +latest("working", "dGhpbmtpbmc=") +-- A failed write must not mark the new state as successfully sent. +write_error = true +local ok, err = pcall(events.render_assistant_text) +assert(not ok and tostring(err):find("panto-report-status:", 1, true)) +write_error = nil +events.render_assistant_text() +latest("working", "cmVzcG9uZGluZw==") +-- Synchronous writes may be short; finish the entire sequence in order. +partial_limit = 3 +events.turn_end() +latest("done") +partial_limit = nil +events.session_end() +latest("clear") +unchanged(events.session_end) +unchanged(function() events.turn_start(); events.background_work_start(); events.background_work_end() end) + +-- /new in the owning process reports; children in the same terminal do not. +activate() +events.session_ready() +latest("idle") +pid = 200.0 +unchanged(activate) +assert(next(events) == nil) +-- A newly created terminal may report, even with an inherited owner marker. +inode = 20 +activate() +assert(env.PANTO_REPORT_STATUS_OWNER == "1:20:2@200") +events.session_ready() +latest("idle") +-- Changing terminals after activation cannot redirect an existing reporter. +inode = 30 +unchanged(events.turn_start) +print("panto-report-status self-check: ok") diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..196f848 --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tasks.check] +description = "Run the self-check without emitting to a live terminal" +dir = "{{config_root}}" +run = "panto lua _selfcheck.lua" diff --git a/panto-report-status-0.1.0-1.rockspec b/panto-report-status-0.1.0-1.rockspec new file mode 100644 index 0000000..981291f --- /dev/null +++ b/panto-report-status-0.1.0-1.rockspec @@ -0,0 +1,27 @@ +rockspec_format = "3.0" +package = "panto-report-status" +version = "0.1.0-1" + +source = { + url = "git+https://code.tjp.lol/public/panto-report-status.git", + tag = "v0.1.0", +} + +description = { + summary = "Terminal-native OSC 7501 lifecycle reporting for panto", + homepage = "https://code.tjp.lol/public/panto-report-status", + license = "MIT", + labels = { "ai", "llm", "agent", "panto", "pantograph", "extension", "terminal" }, +} + +-- panto supplies luv in its embedded Lua runtime. +dependencies = { + "lua >= 5.1", +} + +build = { + type = "builtin", + modules = { + ["panto-report-status"] = "panto-report-status/init.lua", + }, +} diff --git a/panto-report-status/init.lua b/panto-report-status/init.lua new file mode 100644 index 0000000..07c6110 --- /dev/null +++ b/panto-report-status/init.lua @@ -0,0 +1,125 @@ +local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local function base64(text) + local out = {} + for i = 1, #text, 3 do + local a, b, c = text:byte(i, i + 2) + local n = a * 65536 + (b or 0) * 256 + (c or 0) + local function digit(shift) + local index = math.floor(n / 2 ^ shift) % 64 + 1 + return alphabet:sub(index, index) + end + out[#out + 1] = digit(18) .. digit(12) .. (b and digit(6) or "=") .. (c and digit(0) or "=") + end + return table.concat(out) +end + +-- Names are diagnostic labels, not arbitrary terminal text. Keep messages short, +-- ASCII and control-free even when a tool or command supplies an unusual name. +local function label(name) + return (name or "?"):gsub("[^ -~]", "?"):sub(1, 27) +end + +local function command_status(name) + if name == "compact" then return "compacting" end + if name == "!" or name == "!!" then return "shell" end + return "cmd:" .. label(name) +end + +return { + name = "report-status", + activate = function() + local uv = require("luv") + local function terminal_key() + if uv.guess_handle(1) ~= "tty" then return end + local stat = uv.fs_fstat(1) + if stat then return table.concat({ stat.dev, stat.ino, stat.rdev }, ":") end + end + local terminal = terminal_key() + if not terminal then return end + -- Preserve ownership across /new, but don't let a nested panto overwrite + -- or clear its parent's status. A different terminal may report normally. + local owner = terminal .. "@" .. string.format("%.0f", uv.os_getpid()) + local inherited = os.getenv("PANTO_REPORT_STATUS_OWNER") + if inherited ~= owner and (inherited or ""):match("^(.*)@") == terminal then return end + uv.os_setenv("PANTO_REPORT_STATUS_OWNER", owner) + + local panto = require("panto") + local last_state, last_status + local foreground_working, foreground_status = false, nil + local background_work = 0 + local settled_state = "idle" + local ended = false + + local function report(state, status) + if state == last_state and status == last_status then return end + -- Print mode can restore a redirected stdout after activation. Never + -- put control sequences in a pipe, file, or a different terminal. + if terminal_key() ~= terminal then return end + local sequence = "\27]7501;state=" .. state .. ":app=panto" + if status then sequence = sequence .. ":msg=" .. base64(status) end + sequence = sequence .. "\27\\" + local offset = 0 + while offset < #sequence do + local written, err = uv.fs_write(1, sequence:sub(offset + 1), -1) + if not written or written == 0 then + error("panto-report-status: terminal write failed: " .. tostring(err or "short write")) + end + offset = offset + written + end + last_state, last_status = state, status + end + + local function update() + if ended then return end + if foreground_working or background_work > 0 then + report("working", foreground_working and foreground_status or "background") + else + report(settled_state) + end + end + + local function foreground(status) + foreground_working, foreground_status, settled_state = true, status, "done" + update() + end + + local function finish(event) + if foreground_working then + local reason = event and event.reason + settled_state = reason == "failed" and "error" + or ((reason == "interrupted" or reason == "exit_requested") and "idle" or "done") + end + foreground_working, foreground_status = false, nil + update() + end + + panto.ext.on("session_ready", function() + foreground_working, foreground_status, background_work, settled_state = false, nil, 0, "idle" + update() + end) + panto.ext.on("turn_start", function() foreground("thinking") end) + panto.ext.on("agent_submission", function() foreground("thinking") end) + panto.ext.on("render_thinking", function() foreground("thinking") end) + panto.ext.on("command_start", function(event) foreground(command_status(event.command_name)) end) + panto.ext.on("command_end", finish) + panto.ext.on("render_compaction", function() + if foreground_working then foreground("compacting") end + end) + panto.ext.on("render_tool_details", function(event) foreground("tool:" .. label(event.tool_name)) end) + panto.ext.on("render_assistant_text", function() foreground("responding") end) + panto.ext.on("turn_end", finish) + panto.ext.on("background_work_start", function() + if not foreground_working and background_work == 0 then settled_state = "done" end + background_work = background_work + 1 + update() + end) + panto.ext.on("background_work_end", function() + background_work = math.max(0, background_work - 1) + update() + end) + panto.ext.on("session_end", function() + ended = true + report("clear") + end) + end, +} -- cgit v1.3