summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-10-30 15:02:39 -0600
committert <t@tjp.lol>2026-10-30 15:02:45 -0600
commit9e9eb15555d9a657b36ee6ae9ef208c0049588f5 (patch)
tree1a92f1e4b6ce7ba2d6e49ff1e54bfa7919d1072d
initial commit
-rw-r--r--LICENSE18
-rw-r--r--README.md84
-rw-r--r--_selfcheck.lua161
-rw-r--r--mise.toml4
-rw-r--r--panto-report-status-0.1.0-1.rockspec27
-rw-r--r--panto-report-status/init.lua125
6 files changed, 419 insertions, 0 deletions
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:<name>` or `responding` |
+| `command_start` | `working`, message `compacting`, `shell`, or `cmd:<name>` |
+| `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,
+}