@monotykamary/pi-localterm 0.1.2 → 0.3.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,40 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { AGENT_NOTIFY_MIN_ELAPSED_MS } from "../src/constants.js";
3
+ import { extractAssistantExcerpt, 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(
34
+ elapsedMs,
35
+ pi.getSessionName(),
36
+ extractAssistantExcerpt(event.messages),
37
+ );
38
+ process.stdout.write(buildOsc9Sequence(body));
39
+ });
40
+ };
@@ -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.2",
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.3.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,23 @@ 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;
37
+
38
+ // Cap on the assistant-response excerpt carried by the "pi finished"
39
+ // notification body, counted in UTF-16 code units after whitespace is
40
+ // collapsed. Long enough to surface a sentence or two of the agent's final
41
+ // answer so a user who stepped away can tell what concluded; short enough to
42
+ // stay readable in an OS banner (which truncates visually well before the
43
+ // daemon's 1024-unit OSC cap that NOTIFICATION_MAX_LENGTH mirrors).
44
+ export const AGENT_NOTIFY_EXCERPT_MAX_CHARS = 160;
@@ -0,0 +1,67 @@
1
+ import type { AgentEndEvent } from "@earendil-works/pi-coding-agent";
2
+ import { AGENT_NOTIFY_EXCERPT_MAX_CHARS } from "../constants.js";
3
+ import { collapseWhitespace } from "./collapse-whitespace.js";
4
+
5
+ const ELLIPSIS = "…";
6
+
7
+ const formatElapsedSeconds = (elapsedMs: number): string => {
8
+ const totalSeconds = elapsedMs / 1000;
9
+ if (totalSeconds < 60) {
10
+ // Floor to tenths instead of toFixed-rounding so a turn just under a
11
+ // minute never displays as "60.0s".
12
+ const tenths = Math.floor(totalSeconds * 10) / 10;
13
+ return `${tenths.toFixed(1)}s`;
14
+ }
15
+ const minutes = Math.floor(totalSeconds / 60);
16
+ const seconds = Math.floor(totalSeconds % 60);
17
+ return `${minutes}m ${seconds}s`;
18
+ };
19
+
20
+ const truncateWithEllipsis = (text: string, maxChars: number): string =>
21
+ text.length <= maxChars ? text : `${text.slice(0, maxChars - ELLIPSIS.length)}${ELLIPSIS}`;
22
+
23
+ // Pull a preview of the agent's final answer out of an agent_end's messages:
24
+ // the last assistant message that produced visible text, whitespace-collapsed
25
+ // and capped to AGENT_NOTIFY_EXCERPT_MAX_CHARS with an ellipsis. An assistant
26
+ // message is a stream of (TextContent | ThinkingContent | ToolCall) blocks;
27
+ // only TextContent is prose the user would recognize as the response, so
28
+ // thinking (internal reasoning) and tool calls are skipped. Scans backward
29
+ // from the newest message so a turn that ended on a bare tool call (no
30
+ // trailing prose) still surfaces the most recent text the agent actually said.
31
+ // Returns undefined when no assistant message carried text — e.g. the turn
32
+ // was aborted mid-tool-use — so the caller can fall back to identity + elapsed
33
+ // alone. Pure: unit-testable without a session.
34
+ export const extractAssistantExcerpt = (
35
+ messages: AgentEndEvent["messages"],
36
+ ): string | undefined => {
37
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
38
+ const message = messages[index];
39
+ if (message.role !== "assistant") continue;
40
+ const messageText = message.content
41
+ .map((part) => (part.type === "text" ? part.text : ""))
42
+ .join(" ");
43
+ const collapsed = collapseWhitespace(messageText);
44
+ if (collapsed) return truncateWithEllipsis(collapsed, AGENT_NOTIFY_EXCERPT_MAX_CHARS);
45
+ }
46
+ return undefined;
47
+ };
48
+
49
+ // Compose the OSC 9 notification body for a finished agent turn: identity +
50
+ // elapsed time. The notification's arrival already signals "finished", so the
51
+ // text carries who (the pi session name when set, so multiple sessions are
52
+ // distinguishable), what (a truncated excerpt of the final answer when the
53
+ // turn produced one), and how long. The excerpt follows the session name,
54
+ // separated by an em dash, so a short label always precedes the prose. With
55
+ // neither, the colon is dropped to avoid a dangling "pi finished: (1.2s)".
56
+ // Pure: unit-testable without a session.
57
+ export const formatAgentEndBody = (
58
+ elapsedMs: number,
59
+ sessionName?: string,
60
+ excerpt?: string,
61
+ ): string => {
62
+ const elapsed = formatElapsedSeconds(elapsedMs);
63
+ const identity = [sessionName, excerpt]
64
+ .filter((part): part is string => Boolean(part))
65
+ .join(" — ");
66
+ return identity ? `pi finished: ${identity} (${elapsed})` : `pi finished (${elapsed})`;
67
+ };
@@ -0,0 +1,10 @@
1
+ const WHITESPACE_RUN = /\s+/g;
2
+
3
+ // Collapse every run of whitespace (spaces, tabs, newlines) to a single space
4
+ // and strip the leading/trailing edges. The OSC 9 body sanitizer uses this to
5
+ // keep a notification on one visual line, and the agent-end excerpt uses it so
6
+ // truncation is measured against the final visible text rather than raw
7
+ // markdown, which may carry indentation and blank lines that would otherwise
8
+ // eat the character budget before any prose shows. Pure.
9
+ export const collapseWhitespace = (text: string): string =>
10
+ text.replace(WHITESPACE_RUN, " ").trim();
@@ -0,0 +1,33 @@
1
+ import { NOTIFICATION_MAX_LENGTH } from "../constants.js";
2
+ import { collapseWhitespace } from "./collapse-whitespace.js";
3
+
4
+ const OSC9_PREFIX = "\x1b]9;";
5
+ const OSC9_BEL = "\x07";
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 = collapseWhitespace(sanitized);
31
+ const capped = cleaned.slice(0, maxLength);
32
+ return `${OSC9_PREFIX}${capped}${OSC9_BEL}`;
33
+ };