@monotykamary/pi-localterm 0.1.1 → 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 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. Two features, both inert outside localterm:
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
+ };
@@ -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, and (2) scrubs localterm-managed secret env vars from the agent's
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.1.1",
4
- "description": "localterm <-> pi integration: Kitty graphics for the browser renderer, and scrubbing localterm-managed secret env vars from the agent's bash-tool children.",
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"
@@ -32,7 +33,7 @@
32
33
  "@earendil-works/pi-tui": "^0.80.2",
33
34
  "@types/node": "^26.1.0",
34
35
  "typescript": "^6.0.3",
35
- "vite-plus": "^0.2.1"
36
+ "vite-plus": "^0.2.2"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@earendil-works/pi-coding-agent": "*",
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
+ };