pi-focus-bell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/focus-bell.ts +123 -0
- package/package.json +30 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thmtz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# pi-focus-bell
|
|
2
|
+
|
|
3
|
+
Rings your terminal bell when a [pi](https://github.com/earendil-works/pi) session finishes and is waiting on you. Leave a session running, go do something else, and get pulled back the moment it needs input instead of babysitting it.
|
|
4
|
+
|
|
5
|
+
The core behavior is simple: on `agent_end` (pi has finished a turn and is idle), it emits a terminal bell (`BEL`, `\a`).
|
|
6
|
+
|
|
7
|
+
On top of that it is tmux-aware. If the pane running the session is the one you are already looking at, there is nothing to alert you to, so it stays quiet. It only rings when that pane is out of focus. Outside tmux, where focus can't be determined, it rings on every turn end.
|
|
8
|
+
|
|
9
|
+
## Why a bell instead of a desktop notification?
|
|
10
|
+
|
|
11
|
+
The bell isn't just cosmetic. A raw `BEL` propagates through the whole stack: tmux forwards it (`bell-action any`, `visual-bell off`) up the ssh session to the outer terminal, which turns it into audio. One bell covers the local-terminal, tmux, and remote-over-ssh cases with no per-hop config, and works even where no notification daemon exists. (pi's bundled `notify.ts` example takes the other route: OSC desktop notifications, fired on every turn regardless of focus.)
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:pi-focus-bell
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or drop `focus-bell.ts` directly into your global extensions directory (`~/.pi/agent/extensions/`), which pi auto-loads.
|
|
20
|
+
|
|
21
|
+
## Requirements for the audio relay
|
|
22
|
+
|
|
23
|
+
The extension only emits the `BEL`. For it to become an audible sound over tmux + ssh, the outer layers must be configured to carry and play it:
|
|
24
|
+
|
|
25
|
+
**tmux** (`.tmux.conf`):
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
set -g bell-action any
|
|
29
|
+
set -g visual-bell off
|
|
30
|
+
setw -g monitor-bell on
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`visual-bell off` matters: with it on, tmux swallows the audible bell and only flashes the status line.
|
|
34
|
+
|
|
35
|
+
**Your terminal** must play a sound on `BEL`. For example, Ghostty:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
bell-features = audio
|
|
39
|
+
bell-audio-path = /System/Library/Sounds/Funk.aiff
|
|
40
|
+
bell-audio-volume = 0.8
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Two optional environment variables:
|
|
46
|
+
|
|
47
|
+
- `PI_BELL_ALWAYS`: if set, skip focus gating and ring on every turn end, even when you're looking at pi's pane.
|
|
48
|
+
- `PI_BELL_DEBUG`: if set to a file path, append one line per event describing what the extension decided (rang, gated, or failed). Handy for answering "is it even firing?"
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
PI_BELL_DEBUG=/tmp/pi-bell.log pi
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Testing it
|
|
55
|
+
|
|
56
|
+
The bell only rings when pi's pane is unfocused, so you have to switch away before the turn ends:
|
|
57
|
+
|
|
58
|
+
1. Start `pi` in a tmux pane.
|
|
59
|
+
2. Give it a prompt that takes a few seconds and hit Enter.
|
|
60
|
+
3. Immediately switch to another tmux pane or window.
|
|
61
|
+
4. When pi finishes, the terminal should ring.
|
|
62
|
+
|
|
63
|
+
If it stays silent while you are looking at pi's pane, that's the focus gate working as intended.
|
|
64
|
+
|
|
65
|
+
## Known limitations
|
|
66
|
+
|
|
67
|
+
pi computes an internal `willRetry` flag when a turn ends but does not currently expose it to extension events. So when pi hits a transient error (e.g. a 429) and is about to auto-retry, this extension can't tell that apart from a turn that's genuinely waiting on you, and may ring once during the retry backoff. The clean fix is upstream: pi exposing `willRetry` on the extension `agent_end` event.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/focus-bell.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-focus-bell — ring the terminal bell (BEL, \a) when pi finishes a turn and
|
|
3
|
+
* is waiting on you, staying quiet when you're already looking at its pane.
|
|
4
|
+
*
|
|
5
|
+
* Why a bell and not a desktop notification (cf. the bundled notify.ts example)?
|
|
6
|
+
* A raw BEL propagates through the whole stack: tmux (bell-action any,
|
|
7
|
+
* visual-bell off) forwards it up the ssh session to the outer terminal, which
|
|
8
|
+
* plays audio natively (e.g. Ghostty's `bell-features = audio`). So one BEL
|
|
9
|
+
* covers the local-terminal, tmux, and remote-over-ssh cases with no per-hop
|
|
10
|
+
* config, and works even when there's no desktop-notification daemon.
|
|
11
|
+
*
|
|
12
|
+
* Focus gating: inside tmux we only ring when pi's own pane is NOT the active
|
|
13
|
+
* pane of the active window, so it doesn't ping while you're watching it.
|
|
14
|
+
* Outside tmux we can't cheaply know focus, so we always ring.
|
|
15
|
+
*
|
|
16
|
+
* The BEL is written straight to the pane's slave tty (resolved via tmux),
|
|
17
|
+
* bypassing pi's TUI render buffer, with /dev/tty as a fallback.
|
|
18
|
+
*
|
|
19
|
+
* Environment:
|
|
20
|
+
* PI_BELL_ALWAYS if set, skip focus gating and always ring on turn end.
|
|
21
|
+
* PI_BELL_DEBUG if set to a file path, append a line per event describing
|
|
22
|
+
* what the extension decided (rang, gated, failed).
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { openSync, writeSync, closeSync, appendFileSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
const ALWAYS = !!process.env.PI_BELL_ALWAYS;
|
|
28
|
+
const DEBUG_PATH = process.env.PI_BELL_DEBUG;
|
|
29
|
+
|
|
30
|
+
function debug(msg: string): void {
|
|
31
|
+
if (!DEBUG_PATH) return;
|
|
32
|
+
try {
|
|
33
|
+
appendFileSync(DEBUG_PATH, `[${new Date().toISOString()}] ${msg}\n`);
|
|
34
|
+
} catch {
|
|
35
|
+
// Debug logging is best-effort; never let it disrupt the agent.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tmux(args: string[]): string | undefined {
|
|
40
|
+
try {
|
|
41
|
+
return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Active pane id of the active window, or undefined if not resolvable. */
|
|
48
|
+
function activePaneId(): string | undefined {
|
|
49
|
+
const out = tmux([
|
|
50
|
+
"list-panes",
|
|
51
|
+
"-s",
|
|
52
|
+
"-F",
|
|
53
|
+
"#{?pane_active,#{?window_active,#{pane_id},},}",
|
|
54
|
+
]);
|
|
55
|
+
const id = out?.replace(/\s+/g, "");
|
|
56
|
+
return id ? id : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Write a single BEL to `path`, returning true on success. */
|
|
60
|
+
function writeBel(path: string): boolean {
|
|
61
|
+
try {
|
|
62
|
+
const fd = openSync(path, "a");
|
|
63
|
+
try {
|
|
64
|
+
writeSync(fd, "\x07");
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ringBell(): void {
|
|
75
|
+
const pane = process.env.TMUX_PANE;
|
|
76
|
+
|
|
77
|
+
// Not in tmux: ring on the controlling tty unconditionally.
|
|
78
|
+
if (!process.env.TMUX || !pane) {
|
|
79
|
+
debug("not in tmux; ringing /dev/tty");
|
|
80
|
+
writeBel("/dev/tty");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!ALWAYS) {
|
|
85
|
+
// Only ring when we can confirm our pane isn't the focused one. Fail closed:
|
|
86
|
+
// if focus can't be resolved (tmux hiccup, stale $TMUX), stay silent rather
|
|
87
|
+
// than risk ringing the pane you're already watching.
|
|
88
|
+
const active = activePaneId();
|
|
89
|
+
if (!active) {
|
|
90
|
+
debug(`focus unresolved; staying silent (pane=${pane})`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (pane === active) {
|
|
94
|
+
debug(`pane ${pane} is focused; not ringing`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
debug(`pane ${pane} unfocused (active=${active}); ringing`);
|
|
98
|
+
} else {
|
|
99
|
+
debug(`PI_BELL_ALWAYS set; ringing pane ${pane} regardless of focus`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const paneTty = tmux(["display-message", "-p", "-t", pane, "#{pane_tty}"]);
|
|
103
|
+
if (!paneTty || !writeBel(paneTty)) {
|
|
104
|
+
debug(`pane tty unwritable (${paneTty ?? "unresolved"}); falling back to /dev/tty`);
|
|
105
|
+
writeBel("/dev/tty");
|
|
106
|
+
} else {
|
|
107
|
+
debug(`BEL -> ${paneTty}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default function (pi: any) {
|
|
112
|
+
pi.on("agent_end", async (_event: unknown, ctx: any) => {
|
|
113
|
+
// Only meaningful when attached to a terminal.
|
|
114
|
+
if (ctx?.mode && ctx.mode !== "tui") return;
|
|
115
|
+
// NOTE: pi computes a `willRetry` flag on agent_end but does not currently
|
|
116
|
+
// pass it to extension events, so we can't distinguish a "waiting for you"
|
|
117
|
+
// completion from one pi is about to auto-retry after a transient error.
|
|
118
|
+
// Worst case is one extra bell during a retry backoff. A clean fix needs pi
|
|
119
|
+
// to expose `willRetry` on the extension AgentEndEvent.
|
|
120
|
+
debug("agent_end");
|
|
121
|
+
ringBell();
|
|
122
|
+
});
|
|
123
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-focus-bell",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rings the terminal bell when a pi session finishes and is waiting on you, staying quiet when you're already looking at its tmux pane.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"extension",
|
|
10
|
+
"terminal",
|
|
11
|
+
"bell",
|
|
12
|
+
"notification",
|
|
13
|
+
"tmux"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"focus-bell.ts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/thmtz/pi-focus-bell.git"
|
|
24
|
+
},
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./focus-bell.ts"
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
}
|