@monotykamary/pi-localterm 0.1.2 → 0.2.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/README.md +2 -1
- package/extensions/agent-notify.ts +36 -0
- package/extensions/index.ts +7 -2
- package/package.json +3 -2
- package/src/constants.ts +12 -0
- package/src/utils/agent-notify-body.ts +21 -0
- package/src/utils/osc-sequence.ts +33 -0
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @monotykamary/pi-localterm
|
|
2
2
|
|
|
3
|
-
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that integrates [localterm](https://github.com/monotykamary/localterm) with pi.
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi-coding-agent) extension that integrates [localterm](https://github.com/monotykamary/localterm) with pi. Three features, all inert outside localterm:
|
|
4
4
|
|
|
5
5
|
1. **Kitty graphics + OSC 8 links** — localterm renders xterm.js with the Kitty graphics and web-links addons loaded, but sets `TERM=xterm-256color` and strips terminal-identity env vars (so Ink TUIs don't probe for a protocol xterm.js lacks). pi-tui therefore reports images/hyperlinks as unsupported. This extension detects `LOCALTERM=1` (injected into every localterm PTY) and force-enables those capabilities, so images and links render in the browser.
|
|
6
6
|
2. **Secret scrubbing for the agent's bash tool** — localterm injects a secret only into the shimmed process's env (pi's), not its parent shell. But pi's bash tool spawns commands with `{ ...process.env }`, so without this the agent's commands would inherit every secret pi received. This extension overrides the `bash` tool with a spawn hook that deletes the `pi` process's localterm-managed secret env vars from each command's child env only — pi's own `process.env` (and its provider calls) keep them.
|
|
7
|
+
3. **Desktop notifications on agent completion** — pi's only notification primitive is `ctx.ui.notify`, an in-TUI banner invisible once you switch away from the pi tab. localterm already has an OSC 9 (`ESC ] 9 ; MESSAGE BEL`) → browser desktop-notification pipeline (opt-in via "Desktop alerts" in Settings). The extension writes an OSC 9 on `agent_end`, reusing that pipeline so a user who stepped away gets an OS notification when the agent finishes. Threshold-gated (turns ≥ 30 s) so quick back-and-forth doesn't spam a focused user; TUI-mode-guarded so `json`/`rpc`/`-p` stdout isn't polluted with OSC bytes. Note: emitting OSC 9 also fires localterm's `notification` automation trigger, so a `notification`-event automation will fire on agent completion.
|
|
7
8
|
|
|
8
9
|
## Install
|
|
9
10
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AGENT_NOTIFY_MIN_ELAPSED_MS } from "../src/constants.js";
|
|
3
|
+
import { formatAgentEndBody } from "../src/utils/agent-notify-body.js";
|
|
4
|
+
import { buildOsc9Sequence } from "../src/utils/osc-sequence.js";
|
|
5
|
+
|
|
6
|
+
// pi's only notification primitive is ctx.ui.notify — an in-TUI banner that's
|
|
7
|
+
// invisible the moment you switch away from the pi tab. localterm already has
|
|
8
|
+
// a desktop-notification pipeline: anything writing an OSC 9 sequence
|
|
9
|
+
// (ESC ] 9 ; MESSAGE BEL) to the PTY is parsed by the localterm daemon and
|
|
10
|
+
// broadcast to every attached web client, which shows an OS notification when
|
|
11
|
+
// the user enabled "Desktop alerts". pi runs inside that PTY, so this bridges
|
|
12
|
+
// the two by writing an OSC 9 on agent_end.
|
|
13
|
+
//
|
|
14
|
+
// Threshold-gated (AGENT_NOTIFY_MIN_ELAPSED_MS) so quick back-and-forth turns
|
|
15
|
+
// don't spam a user watching the pi tab — only turns long enough that the user
|
|
16
|
+
// likely stepped away ping. Not error-gated: pi's bash tool flags every
|
|
17
|
+
// non-zero exit as an error, so an "always notify on error" rule would fire
|
|
18
|
+
// on every failed test/build and defeat the threshold. Guarded to TUI mode
|
|
19
|
+
// because in json/rpc/print mode pi's stdout is the protocol/answer stream,
|
|
20
|
+
// and an OSC 9 injected there is binary garbage (and a pointless desktop ping
|
|
21
|
+
// for a non-human consumer).
|
|
22
|
+
export const registerAgentNotify = (pi: ExtensionAPI): void => {
|
|
23
|
+
let turnStartedAt: number | undefined;
|
|
24
|
+
|
|
25
|
+
pi.on("agent_start", () => {
|
|
26
|
+
turnStartedAt = Date.now();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
30
|
+
if (ctx.mode !== "tui" || turnStartedAt === undefined) return;
|
|
31
|
+
const elapsedMs = Date.now() - turnStartedAt;
|
|
32
|
+
if (elapsedMs < AGENT_NOTIFY_MIN_ELAPSED_MS) return;
|
|
33
|
+
const body = formatAgentEndBody(elapsedMs, pi.getSessionName());
|
|
34
|
+
process.stdout.write(buildOsc9Sequence(body));
|
|
35
|
+
});
|
|
36
|
+
};
|
package/extensions/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerAgentNotify } from "./agent-notify.js";
|
|
2
3
|
import { registerBashSecretScrub } from "./bash-secret-scrub.js";
|
|
3
4
|
import { registerKittyImages } from "./kitty-images.js";
|
|
4
5
|
|
|
@@ -6,11 +7,15 @@ import { registerKittyImages } from "./kitty-images.js";
|
|
|
6
7
|
// injected into every localterm PTY; when it's absent, nothing is registered
|
|
7
8
|
// and pi behaves exactly as default. Inside localterm this (1) force-enables
|
|
8
9
|
// Kitty graphics + OSC 8 links the xterm.js renderer supports but pi-tui can't
|
|
9
|
-
// detect,
|
|
10
|
+
// detect, (2) scrubs localterm-managed secret env vars from the agent's
|
|
10
11
|
// bash-tool children so a generated command can't read keys the shim injected
|
|
11
|
-
// into pi's own env
|
|
12
|
+
// into pi's own env, and (3) writes an OSC 9 desktop notification on
|
|
13
|
+
// agent_end (reusing localterm's existing OSC 9 -> browser notification
|
|
14
|
+
// pipeline) so a user who stepped away from the pi tab learns the agent
|
|
15
|
+
// finished.
|
|
12
16
|
export default (pi: ExtensionAPI): void => {
|
|
13
17
|
if (process.env.LOCALTERM !== "1") return;
|
|
14
18
|
registerKittyImages(pi);
|
|
15
19
|
registerBashSecretScrub(pi);
|
|
20
|
+
registerAgentNotify(pi);
|
|
16
21
|
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-localterm",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "localterm <-> pi integration: Kitty graphics for the browser renderer,
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "localterm <-> pi integration: Kitty graphics for the browser renderer, scrubbing localterm-managed secret env vars from the agent's bash-tool children, and OSC 9 desktop notifications when the agent finishes a long turn.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"localterm",
|
|
7
|
+
"notifications",
|
|
7
8
|
"pi",
|
|
8
9
|
"pi-package",
|
|
9
10
|
"secrets"
|
package/src/constants.ts
CHANGED
|
@@ -22,3 +22,15 @@ export const PI_SETTINGS_FILENAME = "settings.json";
|
|
|
22
22
|
export const SECRET_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
23
23
|
export const PROCESS_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
|
|
24
24
|
export const ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
25
|
+
|
|
26
|
+
// Mirror localterm-server's MAX_NOTIFICATION_LENGTH: the daemon slices any
|
|
27
|
+
// OSC 9 body past this many UTF-16 code units, which can split a surrogate
|
|
28
|
+
// pair. We cap the body ourselves before framing so the emitted sequence is
|
|
29
|
+
// always within the daemon's limit and never split. Used by the OSC 9
|
|
30
|
+
// notification builder.
|
|
31
|
+
export const NOTIFICATION_MAX_LENGTH = 1024;
|
|
32
|
+
|
|
33
|
+
// Only push a desktop notification when the agent turn ran at least this long,
|
|
34
|
+
// so quick back-and-forth turns don't spam a user actively watching the pi
|
|
35
|
+
// tab. Tunable: lower to notify sooner, raise to stay quieter while focused.
|
|
36
|
+
export const AGENT_NOTIFY_MIN_ELAPSED_MS = 30_000;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const formatElapsedSeconds = (elapsedMs: number): string => {
|
|
2
|
+
const totalSeconds = elapsedMs / 1000;
|
|
3
|
+
if (totalSeconds < 60) {
|
|
4
|
+
// Floor to tenths instead of toFixed-rounding so a turn just under a
|
|
5
|
+
// minute never displays as "60.0s".
|
|
6
|
+
const tenths = Math.floor(totalSeconds * 10) / 10;
|
|
7
|
+
return `${tenths.toFixed(1)}s`;
|
|
8
|
+
}
|
|
9
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
10
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
11
|
+
return `${minutes}m ${seconds}s`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Compose the OSC 9 notification body for a finished agent turn: identity +
|
|
15
|
+
// elapsed time. The notification's arrival already signals "finished", so the
|
|
16
|
+
// text carries only who (the pi session name when set, so multiple sessions
|
|
17
|
+
// are distinguishable) and how long. Pure: unit-testable without a session.
|
|
18
|
+
export const formatAgentEndBody = (elapsedMs: number, sessionName?: string): string => {
|
|
19
|
+
const elapsed = formatElapsedSeconds(elapsedMs);
|
|
20
|
+
return sessionName ? `pi finished: ${sessionName} (${elapsed})` : `pi finished (${elapsed})`;
|
|
21
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NOTIFICATION_MAX_LENGTH } from "../constants.js";
|
|
2
|
+
|
|
3
|
+
const OSC9_PREFIX = "\x1b]9;";
|
|
4
|
+
const OSC9_BEL = "\x07";
|
|
5
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
6
|
+
|
|
7
|
+
// A BEL in the body would terminate the OSC sequence early and leak the rest
|
|
8
|
+
// as visible garbage; an ESC would let the body forge an ST terminator. Other
|
|
9
|
+
// control chars (C0 0x00-0x1F + DEL 0x7F) render as noise in a desktop
|
|
10
|
+
// notification, so replace them all with spaces. Done as a code-point check
|
|
11
|
+
// rather than a control-char regex so eslint's no-control-regex rule stays
|
|
12
|
+
// clean (and so astral characters survive unsplit).
|
|
13
|
+
const isControlOrDel = (char: string): boolean => {
|
|
14
|
+
const code = char.codePointAt(0) ?? 0;
|
|
15
|
+
return code <= 0x1f || code === 0x7f;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Build an OSC 9 "Terminal Notification" sequence (ESC ] 9 ; MESSAGE BEL)
|
|
19
|
+
// safe to write to a PTY the localterm daemon parses. Replaces control chars
|
|
20
|
+
// with spaces (then collapses whitespace and trims), and caps the body to
|
|
21
|
+
// `maxLength` UTF-16 code units. localterm's daemon slices any body past its
|
|
22
|
+
// own 1024-unit cap and can split a surrogate pair there; capping first keeps
|
|
23
|
+
// the emitted body within the daemon's limit. Pure: unit-testable without a
|
|
24
|
+
// TTY.
|
|
25
|
+
export const buildOsc9Sequence = (
|
|
26
|
+
body: string,
|
|
27
|
+
maxLength: number = NOTIFICATION_MAX_LENGTH,
|
|
28
|
+
): string => {
|
|
29
|
+
const sanitized = Array.from(body, (char) => (isControlOrDel(char) ? " " : char)).join("");
|
|
30
|
+
const cleaned = sanitized.replace(WHITESPACE_RUN, " ").trim();
|
|
31
|
+
const capped = cleaned.slice(0, maxLength);
|
|
32
|
+
return `${OSC9_PREFIX}${capped}${OSC9_BEL}`;
|
|
33
|
+
};
|