From 7d90d5be14be841d6d37fc538bd6a1cdc687e35b Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 10 Nov 2026 11:18:32 -0500 Subject: [PATCH 1/2] pi: show session status in tmux window labels --- pi/.pi/agent/extensions/tmux-status/README.md | 76 ++++++ pi/.pi/agent/extensions/tmux-status/index.ts | 73 ++++++ .../agent/extensions/tmux-status/runtime.ts | 87 ++++++ .../extensions/tmux-status/status.test.ts | 248 ++++++++++++++++++ tmux/.config/tmux/tmux.conf | 14 +- 5 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 pi/.pi/agent/extensions/tmux-status/README.md create mode 100644 pi/.pi/agent/extensions/tmux-status/index.ts create mode 100644 pi/.pi/agent/extensions/tmux-status/runtime.ts create mode 100644 pi/.pi/agent/extensions/tmux-status/status.test.ts diff --git a/pi/.pi/agent/extensions/tmux-status/README.md b/pi/.pi/agent/extensions/tmux-status/README.md new file mode 100644 index 0000000..561bc14 --- /dev/null +++ b/pi/.pi/agent/extensions/tmux-status/README.md @@ -0,0 +1,76 @@ +# Pi status in tmux + +A local, dependency-free Pi extension. No npm Pi package or TPM plugin is needed. +It is auto-discovered through the Stow-managed `~/.pi/agent/extensions/` directory. +Run `/reload` in existing Pi sessions, then reload tmux with **prefix + r**. + +The single status row shows window numbers and names, with a badge for windows +containing live, interactive Pi sessions, for example: + +```text + 0 editor [pi:working] 1 backend 2 review [pi:waiting] +``` + +The current window's summary is highlighted using reverse video. No hard-coded +colors, AI naming, completion sounds, or window renaming are involved. +**Prefix + Space** hides or restores the status bar. + +## States + +- `idle`: Pi is open but has not completed a run in this session, or was aborted. +- `working`: a run (including tools, retries, queued continuations, or compaction) + is in progress. +- `waiting`: a blocking extension UI prompt needs input. A question written in + normal assistant text is not detectable as a UI prompt. +- `done`: the full run settled. This is a status, not an unread notification: it + remains until the next run, session replacement/reload, or exit. Visiting the + window does not clear it. It does not distinguish a successful answer from a + provider error; an aborted run returns to `idle` instead. + +Each window combines its panes with priority **waiting > working > done > idle**. +One pane finishing or exiting cannot erase another pane's activity. State follows +panes when they move between windows. Only one foreground Pi TUI per pane is +supported; a newer TUI supersedes the previous owner. Print, JSON, and RPC runs +are ignored even when they inherit `TMUX_PANE`, including delegated children. +The parent remains `working` while it awaits a delegated tool; detached children +are not tracked independently. + +## Implementation + +`index.ts` observes Pi's lifecycle. In particular, `agent_settled`, not +`agent_end`, marks completion. `runtime.ts` serializes bounded tmux calls and +publishes these pane-local user options: + +- `@pi_status`: the state word. +- `@pi_status_until`: lease expiration, in Unix seconds. +- `@pi_status_owner`: an opaque token protecting replacement runtimes. + +A five-second heartbeat renews the twenty-second lease. Graceful shutdown +(including `/reload`, `/new`, and `/resume`) clears the options and timer. After a +crash or SIGKILL, tmux stops rendering the expired state within roughly twenty +seconds, without needing another Pi session to clean it up. Stale options may +remain until the pane is closed or claimed again. tmux failures never block an +agent operation beyond the command timeout. + +`tmux/.config/tmux/tmux.conf` owns all presentation. Its pane loop ignores expired +leases and its window formats render the badge inline, so there is no shared +window-wide status writer and no aggregation race between Pi processes. It uses +tmux's normal one-second status refresh, without shell commands on each redraw. +No prompts, paths, credentials, or conversation text are stored or exposed. + +This follows the state-option approach of +[pi-tmux-status](https://github.com/muzammil-iftikhar/pi-tmux-status), but is an +independent implementation rather than copied upstream source. + +## Validation + +From the dotfiles repository root, using Node 24+: + +```sh +node --test pi/.pi/agent/extensions/tmux-status/status.test.ts +``` + +The suite covers lifecycle transitions, prompt waiting, compaction, noninteractive +children, heartbeats, and shutdown. It also starts an isolated tmux server to test +the real formats, cross-pane priority, inline badges, expired leases, pane +moves, and owner-safe cleanup. That integration test skips if tmux is unavailable. diff --git a/pi/.pi/agent/extensions/tmux-status/index.ts b/pi/.pi/agent/extensions/tmux-status/index.ts new file mode 100644 index 0000000..49a8413 --- /dev/null +++ b/pi/.pi/agent/extensions/tmux-status/index.ts @@ -0,0 +1,73 @@ +import { randomUUID } from "node:crypto"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { PaneStatus } from "./runtime.ts"; + +export default function (pi: ExtensionAPI) { + const pane = process.env.TMUX_PANE; + if (!process.env.TMUX || !pane || !/^%\d+$/.test(pane)) return; + + let status: PaneStatus | undefined; + let working = false; + let waiting = false; + let compacting = false; + let finished = false; + + const update = () => status?.update( + waiting ? "waiting" : working || compacting ? "working" : finished ? "done" : "idle", + ); + + pi.on("session_start", async (_event, ctx) => { + // Print/JSON/RPC children inherit TMUX_PANE but do not own its terminal. + // In particular, a delegated child must not replace its parent's state. + if (ctx.mode !== "tui") return; + working = !ctx.isIdle(); + waiting = false; + compacting = false; + finished = false; + status = new PaneStatus( + (args) => pi.exec("tmux", args, { timeout: 1_000, maxBuffer: 16_384 }), + pane, + randomUUID(), + ); + await status.start(working ? "working" : "idle"); + }); + + const start = () => { + working = true; + finished = false; + return update(); + }; + pi.on("before_agent_start", start); + pi.on("agent_start", start); + // agent_end is not final: retries, compaction, and queued prompts may follow. + pi.on("agent_settled", (event) => { + working = false; + finished = !event.aborted; + return update(); + }); + + pi.on("ui_prompt_start", () => { + waiting = true; + return update(); + }); + pi.on("ui_prompt_end", () => { + waiting = false; + return update(); + }); + + pi.on("session_before_compact", () => { + compacting = true; + return update(); + }); + const endCompaction = () => { + compacting = false; + return update(); + }; + pi.on("session_compact", endCompaction); + pi.on("session_compact_failed", endCompaction); + + pi.on("session_shutdown", async () => { + await status?.stop(); + status = undefined; + }); +} diff --git a/pi/.pi/agent/extensions/tmux-status/runtime.ts b/pi/.pi/agent/extensions/tmux-status/runtime.ts new file mode 100644 index 0000000..0b6ee2e --- /dev/null +++ b/pi/.pi/agent/extensions/tmux-status/runtime.ts @@ -0,0 +1,87 @@ +export type Status = "idle" | "working" | "waiting" | "done"; +export type RunTmux = (args: string[]) => Promise<{ code: number }>; + +export const HEARTBEAT_MS = 5_000; +export const LEASE_SECONDS = 20; + +// Only one foreground TUI owns a pane. The token prevents a superseded runtime +// (for example during reload) from updating or clearing its successor's state. +export class PaneStatus { + private state: Status = "idle"; + private stopped = false; + private queue: Promise = Promise.resolve(); + private timer: ReturnType | undefined; + + private readonly run: RunTmux; + private readonly pane: string; + private readonly owner: string; + + constructor(run: RunTmux, pane: string, owner: string) { + // These values are also interpolated into tmux commands, never shell code. + if (!/^%\d+$/.test(pane) || !/^[a-zA-Z0-9-]+$/.test(owner)) { + throw new Error("Invalid tmux pane or owner"); + } + this.run = run; + this.pane = pane; + this.owner = owner; + } + + private enqueue(command: () => Promise<{ code: number }>): Promise { + this.queue = this.queue.then(async () => { + try { + await command(); + } catch { + // A missing tmux binary, closed pane, or dead server must not break Pi. + } + }); + return this.queue; + } + + private guarded(command: string): Promise<{ code: number }> { + return this.run([ + "if-shell", "-F", "-t", this.pane, + `#{==:#{@pi_status_owner},${this.owner}}`, command, + ]); + } + + private publish(): Promise { + const state = this.state; + return this.enqueue(() => { + const until = Math.floor(Date.now() / 1_000) + LEASE_SECONDS; + return this.guarded( + `set-option -p -t ${this.pane} @pi_status ${state} ; ` + + `set-option -p -t ${this.pane} @pi_status_until ${until}`, + ); + }); + } + + async start(state: Status): Promise { + this.state = state; + await this.enqueue(() => this.run([ + "set-option", "-p", "-t", this.pane, "@pi_status_owner", this.owner, + ])); + if (this.stopped) return; + await this.publish(); + if (this.stopped) return; + this.timer = setInterval(() => void this.publish(), HEARTBEAT_MS); + this.timer.unref(); + } + + update(state: Status): Promise { + if (this.stopped || state === this.state) return this.queue; + this.state = state; + return this.publish(); + } + + stop(): Promise { + if (this.stopped) return this.queue; + this.stopped = true; + clearInterval(this.timer); + this.timer = undefined; + return this.enqueue(() => this.guarded( + `set-option -pu -t ${this.pane} @pi_status ; ` + + `set-option -pu -t ${this.pane} @pi_status_until ; ` + + `set-option -pu -t ${this.pane} @pi_status_owner`, + )); + } +} diff --git a/pi/.pi/agent/extensions/tmux-status/status.test.ts b/pi/.pi/agent/extensions/tmux-status/status.test.ts new file mode 100644 index 0000000..1b13caf --- /dev/null +++ b/pi/.pi/agent/extensions/tmux-status/status.test.ts @@ -0,0 +1,248 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { promisify } from "node:util"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import extension from "./index.ts"; +import { HEARTBEAT_MS, PaneStatus, type RunTmux } from "./runtime.ts"; + +const exec = promisify(execFile); +type Context = { mode: string; isIdle: () => boolean }; +type Handler = (event: { aborted?: boolean }, ctx: Context) => unknown; + +function harness(run: RunTmux = async () => ({ code: 0 }), insideTmux = true) { + const handlers = new Map(); + const calls: string[][] = []; + const previous = { TMUX: process.env.TMUX, TMUX_PANE: process.env.TMUX_PANE }; + try { + process.env.TMUX = insideTmux ? "/tmp/test-tmux,1,0" : ""; + process.env.TMUX_PANE = insideTmux ? "%0" : ""; + extension({ + on(name: string, handler: Handler) { handlers.set(name, handler); }, + exec(_command: string, args: string[]) { calls.push(args); return run(args); }, + } as unknown as ExtensionAPI); + } finally { + for (const key of ["TMUX", "TMUX_PANE"] as const) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } + return { + calls, + handlers, + async emit(name: string, event = {}, ctx: Context = { mode: "tui", isIdle: () => true }) { + await handlers.get(name)?.(event, ctx); + }, + }; +} + +function lastState(calls: string[][]) { + return calls.at(-1)?.at(-1)?.match(/@pi_status (\w+)/)?.[1]; +} + +test("lifecycle distinguishes prompts, retries, completion, and aborts", async (t) => { + const h = harness(); + t.after(() => h.emit("session_shutdown")); + assert.equal(h.calls.length, 0, "no subprocesses or timers at factory time"); + await h.emit("session_start"); + assert.equal(lastState(h.calls), "idle"); + await h.emit("before_agent_start"); + assert.equal(lastState(h.calls), "working"); + const count = h.calls.length; + await h.emit("agent_start"); + assert.equal(h.calls.length, count, "duplicate working state is coalesced"); + await h.emit("ui_prompt_start"); + assert.equal(lastState(h.calls), "waiting"); + await h.emit("ui_prompt_end"); + assert.equal(lastState(h.calls), "working"); + assert.equal(h.handlers.has("agent_end"), false, "low-level completion is not idle"); + await h.emit("agent_settled", { aborted: false }); + assert.equal(lastState(h.calls), "done"); + await h.emit("agent_start"); + await h.emit("agent_settled", { aborted: true }); + assert.equal(lastState(h.calls), "idle", "an aborted run is not advertised as done"); +}); + +test("compaction is busy and returns to the preceding state, including failure", async (t) => { + const h = harness(); + t.after(() => h.emit("session_shutdown")); + await h.emit("session_start"); + await h.emit("session_before_compact"); + assert.equal(lastState(h.calls), "working"); + await h.emit("session_compact_failed"); + assert.equal(lastState(h.calls), "idle"); + await h.emit("agent_start"); + await h.emit("agent_settled"); + await h.emit("session_before_compact"); + assert.equal(lastState(h.calls), "working"); + await h.emit("session_compact"); + assert.equal(lastState(h.calls), "done"); +}); + +test("non-tmux and noninteractive invocations never claim a pane", async () => { + const outside = harness(undefined, false); + assert.equal(outside.handlers.size, 0); + for (const mode of ["print", "json", "rpc"]) { + const h = harness(); + await h.emit("session_start", {}, { mode, isIdle: () => false }); + await h.emit("agent_start"); + await h.emit("agent_settled"); + await h.emit("session_shutdown"); + assert.deepEqual(h.calls, [], mode); + } +}); + +test("heartbeat renews state and shutdown stops it", async (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const h = harness(); + t.after(() => h.emit("session_shutdown")); + await h.emit("session_start"); + const count = h.calls.length; + t.mock.timers.tick(HEARTBEAT_MS); + // Queue another state change to wait until the heartbeat write has drained. + await h.emit("agent_start"); + assert.equal(h.calls.length, count + 2); + assert.match(h.calls[count].at(-1)!, /@pi_status idle/); + await h.emit("session_shutdown"); + const stoppedCount = h.calls.length; + t.mock.timers.tick(HEARTBEAT_MS * 3); + await h.emit("agent_start"); + assert.equal(h.calls.length, stoppedCount); +}); + +test("overlapping UI events are serialized before shutdown cleanup", async () => { + const h = harness(); + await h.emit("session_start"); + const count = h.calls.length; + await Promise.all([ + h.emit("agent_start"), + h.emit("ui_prompt_start"), + h.emit("ui_prompt_end"), + h.emit("session_shutdown"), + ]); + const commands = h.calls.slice(count).map((args) => args.at(-1)!); + assert.match(commands[0], /@pi_status working/); + assert.match(commands[1], /@pi_status waiting/); + assert.match(commands[2], /@pi_status working/); + assert.match(commands[3], /set-option -pu/); +}); + +test("failed tmux commands do not reject lifecycle events", async () => { + for (const run of [ + async () => { throw new Error("tmux unavailable"); }, + async () => ({ code: 1 }), + ]) { + const h = harness(run); + await h.emit("session_start"); + await h.emit("agent_start"); + await h.emit("session_shutdown"); + await h.emit("session_shutdown"); + } +}); + +test("tmux renders per-window aggregates, leases, pane moves, and ownership", async (t) => { + try { + await exec("tmux", ["-V"]); + } catch { + t.skip("tmux is not installed"); + return; + } + const directory = await mkdtemp(join(tmpdir(), "pi-tmux-status-")); + const socket = join(directory, "socket"); + const tmux = async (...args: string[]) => { + const { stdout, stderr } = await exec("tmux", ["-S", socket, ...args], { timeout: 3_000 }); + assert.equal(stderr, ""); + return stdout.trim(); + }; + const statuses: PaneStatus[] = []; + t.after(async () => { + for (const status of statuses) await status.stop(); + try { await tmux("kill-server"); } catch { /* already stopped */ } + await rm(directory, { recursive: true, force: true }); + }); + const pane1 = await tmux("-f", "/dev/null", "new-session", "-d", "-P", "-F", "#{pane_id}", "-s", "check", "-n", "editor", "/bin/sh"); + const pane2 = await tmux("split-window", "-d", "-P", "-F", "#{pane_id}", "/bin/sh"); + const config = await readFile(new URL("../../../../../tmux/.config/tmux/tmux.conf", import.meta.url), "utf8"); + // Exercise the repository's actual formats, without loading plugins or bindings. + const formats = config.split("\n").filter((line) => /^set -g (?:@pi_status_|window-status-(?:current-)?format )/.test(line)); + assert.equal(formats.length, 5); + const configFile = join(directory, "formats.conf"); + await writeFile(configFile, formats.join("\n") + "\n"); + await tmux("source-file", configFile); + const failures: unknown[] = []; + const run: RunTmux = async (args) => { + try { + await tmux(...args); + return { code: 0 }; + } catch (error) { + failures.push(error); + return { code: 1 }; + } + }; + const create = (pane: string, owner: string) => { + const status = new PaneStatus(run, pane, owner); + statuses.push(status); + return status; + }; + const badge = (target: string) => tmux("display-message", "-p", "-t", target, "#{E:@pi_status_badge}"); + const first = create(pane1, "first"); + const second = create(pane2, "second"); + await first.start("idle"); + assert.deepEqual(failures, []); + assert.equal(await badge(pane1), "[pi:idle]", await tmux("display-message", "-p", "-t", pane1, "#{@pi_status} / #{@pi_status_until} / #{T:@pi_status_time}")); + await first.update("working"); + await second.start("done"); + assert.equal(await badge(pane1), "[pi:working]"); + assert.equal(await badge(pane2), "[pi:working]", "inactive panes contribute too"); + await second.update("waiting"); + assert.equal(await badge(pane1), "[pi:waiting]"); + await second.stop(); + assert.equal(await badge(pane1), "[pi:working]", "one pane exiting cannot clear another"); + await first.update("done"); + assert.equal(await badge(pane1), "[pi:done]"); + for (const option of ["window-status-format", "window-status-current-format"]) { + const format = await tmux("show-options", "-gv", option); + const rendered = await tmux("display-message", "-p", "-t", pane1, format); + assert.match(rendered, /0 editor\s+\[pi:done\]/, "Pi status follows the window name inline"); + } + const windowList = "#{W:#{E:window-status-format},#{E:window-status-current-format}}"; + assert.equal(await tmux("display-message", "-p", "-t", pane1, "#{window_name}"), "editor"); + await tmux("set-option", "-p", "-t", pane1, "@pi_status_until", "1"); + assert.equal(await badge(pane1), "", "expired leases disappear without a running extension"); + await first.update("working"); + const otherPane = await tmux("new-window", "-d", "-P", "-F", "#{pane_id}", "-n", "other", "/bin/sh"); + assert.equal(await badge(otherPane), "", "unrelated windows have no badge"); + await tmux("join-pane", "-d", "-s", pane1, "-t", otherPane); + assert.equal(await badge(pane2), "", "moving a pane leaves no stale aggregate"); + assert.equal(await badge(otherPane), "[pi:working]"); + const replacement = create(pane1, "replacement"); + await replacement.start("waiting"); + await first.update("done"); + await first.stop(); + assert.equal(await badge(otherPane), "[pi:waiting]", "old runtimes cannot alter a successor"); + const third = create(pane2, "third"); + await third.start("done"); + const row = await tmux("display-message", "-p", "-t", pane2, windowList); + assert.match(row, /0 editor \[pi:done\]/); + assert.match(row, /1 other \[pi:waiting\]/, "background windows have inline badges too"); + await replacement.stop(); + assert.equal(await badge(otherPane), ""); + assert.equal(await tmux("show-options", "-p", "-t", pane1), "", "shutdown removes all pane-local state"); + + assert.deepEqual(failures, [], "no tmux commands failed during the integration test"); + + // Also parse the complete config, omitting only third-party plugin startup. + await writeFile(configFile, config.replace("run -b '~/.config/tmux/plugins/tpm/tpm'", "")); + await tmux("set-option", "-g", "status", "2"); + await tmux("source-file", configFile); + assert.equal(await tmux("show-options", "-gv", "status"), "on"); + const bindings = await tmux("list-keys", "-T", "prefix"); + assert.match(bindings, /Space\s+set-option -g status/); + for (const expected of ["off", "on"]) { + await tmux("set-option", "-g", "status"); + assert.equal(await tmux("show-options", "-gv", "status"), expected); + } +}); diff --git a/tmux/.config/tmux/tmux.conf b/tmux/.config/tmux/tmux.conf index 4e5604a..47e07ca 100644 --- a/tmux/.config/tmux/tmux.conf +++ b/tmux/.config/tmux/tmux.conf @@ -18,7 +18,7 @@ set -g status-keys vi # vi bindings in tmux command prompt set -s escape-time 0 # address vim mode switching delay (http://superuser.com/a/252717/65504) set-option -g display-panes-time 3000 # display pane indicators for 3 seconds set-option -g renumber-windows on # Automatic renumbering -set-option -g status off # Default to hiding the status bar +set-option -g status on # Show the status bar by default set-window-option -g mode-keys vi # Use vim keybindings in copy mode # Recommended for use with `pi` @@ -60,13 +60,19 @@ set -g status-interval 1 set -g status-left-length 64 set -g status-left "" set -g status-right "" -set -g status-justify "right" +set -g status-justify "centre" set -g status-position "top" set -g pane-border-status off set -g pane-border-format '#{pane_current_command} #{pane_current_path}' set -g window-status-separator " " -set -g window-status-format " #I.#P " -set -g window-status-current-format " #{?window_zoomed_flag,*,}#I.#P " + +# requires `.pi/agent/extensions/tmux-status` +set -g @pi_status_time '%s' +set -g @pi_status_live '#{?#{e|>:#{@pi_status_until},#{T:@pi_status_time}},#{@pi_status},}' +set -g @pi_status_badge '#{?#{m:*waiting*,#{P:#{E:@pi_status_live}}}, [pi:waiting],#{?#{m:*working*,#{P:#{E:@pi_status_live}}}, [pi:working],#{?#{m:*done*,#{P:#{E:@pi_status_live}}}, [pi:done],#{?#{m:*idle*,#{P:#{E:@pi_status_live}}}, [pi:idle],}}}}' + +set -g window-status-format " #I #W#{E:@pi_status_badge} " +set -g window-status-current-format " #{?window_zoomed_flag,*,} #I #W#{E:@pi_status_badge} " # if-shell -b '[ "$(uname)" = "Darwin" ]' { # set -g status-right "C #{cpu_percentage}% M #{ram_percentage}% %Y-%m-%d %H:%M" From 46aa18b9f5078499e9fdd0a9d078778d5d96a72a Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Tue, 10 Nov 2026 11:24:01 -0500 Subject: [PATCH 2/2] pi: simplify tmux status readme --- pi/.pi/agent/extensions/tmux-status/README.md | 73 +++---------------- 1 file changed, 12 insertions(+), 61 deletions(-) diff --git a/pi/.pi/agent/extensions/tmux-status/README.md b/pi/.pi/agent/extensions/tmux-status/README.md index 561bc14..b95aa0f 100644 --- a/pi/.pi/agent/extensions/tmux-status/README.md +++ b/pi/.pi/agent/extensions/tmux-status/README.md @@ -1,76 +1,27 @@ -# Pi status in tmux +# tmux-status -A local, dependency-free Pi extension. No npm Pi package or TPM plugin is needed. -It is auto-discovered through the Stow-managed `~/.pi/agent/extensions/` directory. -Run `/reload` in existing Pi sessions, then reload tmux with **prefix + r**. - -The single status row shows window numbers and names, with a badge for windows -containing live, interactive Pi sessions, for example: +Inject pi status into tmux statuslines. ```text 0 editor [pi:working] 1 backend 2 review [pi:waiting] ``` -The current window's summary is highlighted using reverse video. No hard-coded -colors, AI naming, completion sounds, or window renaming are involved. -**Prefix + Space** hides or restores the status bar. - ## States - `idle`: Pi is open but has not completed a run in this session, or was aborted. -- `working`: a run (including tools, retries, queued continuations, or compaction) - is in progress. -- `waiting`: a blocking extension UI prompt needs input. A question written in - normal assistant text is not detectable as a UI prompt. -- `done`: the full run settled. This is a status, not an unread notification: it - remains until the next run, session replacement/reload, or exit. Visiting the - window does not clear it. It does not distinguish a successful answer from a - provider error; an aborted run returns to `idle` instead. +- `working`: a run (including tools, retries, queued continuations, or compaction) is in progress. +- `waiting`: a blocking extension UI prompt needs input. +- `done`: the full run settled. Each window combines its panes with priority **waiting > working > done > idle**. -One pane finishing or exiting cannot erase another pane's activity. State follows -panes when they move between windows. Only one foreground Pi TUI per pane is -supported; a newer TUI supersedes the previous owner. Print, JSON, and RPC runs -are ignored even when they inherit `TMUX_PANE`, including delegated children. -The parent remains `working` while it awaits a delegated tool; detached children -are not tracked independently. - -## Implementation - -`index.ts` observes Pi's lifecycle. In particular, `agent_settled`, not -`agent_end`, marks completion. `runtime.ts` serializes bounded tmux calls and -publishes these pane-local user options: - -- `@pi_status`: the state word. -- `@pi_status_until`: lease expiration, in Unix seconds. -- `@pi_status_owner`: an opaque token protecting replacement runtimes. -A five-second heartbeat renews the twenty-second lease. Graceful shutdown -(including `/reload`, `/new`, and `/resume`) clears the options and timer. After a -crash or SIGKILL, tmux stops rendering the expired state within roughly twenty -seconds, without needing another Pi session to clean it up. Stale options may -remain until the pane is closed or claimed again. tmux failures never block an -agent operation beyond the command timeout. +## Usage -`tmux/.config/tmux/tmux.conf` owns all presentation. Its pane loop ignores expired -leases and its window formats render the badge inline, so there is no shared -window-wide status writer and no aggregation race between Pi processes. It uses -tmux's normal one-second status refresh, without shell commands on each redraw. -No prompts, paths, credentials, or conversation text are stored or exposed. - -This follows the state-option approach of -[pi-tmux-status](https://github.com/muzammil-iftikhar/pi-tmux-status), but is an -independent implementation rather than copied upstream source. - -## Validation - -From the dotfiles repository root, using Node 24+: - -```sh -node --test pi/.pi/agent/extensions/tmux-status/status.test.ts ``` +set -g @pi_status_time '%s' +set -g @pi_status_live '#{?#{e|>:#{@pi_status_until},#{T:@pi_status_time}},#{@pi_status},}' +set -g @pi_status_badge '#{?#{m:*waiting*,#{P:#{E:@pi_status_live}}}, [pi:waiting],#{?#{m:*working*,#{P:#{E:@pi_status_live}}}, [pi:working],#{?#{m:*done*,#{P:#{E:@pi_status_live}}}, [pi:done],#{?#{m:*idle*,#{P:#{E:@pi_status_live}}}, [pi:idle],}}}}' -The suite covers lifecycle transitions, prompt waiting, compaction, noninteractive -children, heartbeats, and shutdown. It also starts an isolated tmux server to test -the real formats, cross-pane priority, inline badges, expired leases, pane -moves, and owner-safe cleanup. That integration test skips if tmux is unavailable. +set -g window-status-format " #I #W#{E:@pi_status_badge} " +set -g window-status-current-format " #{?window_zoomed_flag,*,} #I #W#{E:@pi_status_badge} " +```