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..b95aa0f --- /dev/null +++ b/pi/.pi/agent/extensions/tmux-status/README.md @@ -0,0 +1,27 @@ +# tmux-status + +Inject pi status into tmux statuslines. + +```text + 0 editor [pi:working] 1 backend 2 review [pi:waiting] +``` + +## 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. +- `done`: the full run settled. + +Each window combines its panes with priority **waiting > working > done > idle**. + +## Usage + +``` +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} " +``` 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"