Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions pi/.pi/agent/extensions/tmux-status/README.md
Original file line number Diff line number Diff line change
@@ -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} "
```
73 changes: 73 additions & 0 deletions pi/.pi/agent/extensions/tmux-status/index.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
87 changes: 87 additions & 0 deletions pi/.pi/agent/extensions/tmux-status/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<void> = Promise.resolve();
private timer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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<void> {
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<void> {
if (this.stopped || state === this.state) return this.queue;
this.state = state;
return this.publish();
}

stop(): Promise<void> {
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`,
));
}
}
Loading
Loading