@monotykamary/pi-localterm 0.3.1 → 0.3.2

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
@@ -4,7 +4,7 @@ A [pi](https://github.com/earendil-works/pi-coding-agent) extension that integra
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
+ 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_settled`, reusing that pipeline so a user who stepped away gets an OS notification only after the agent has no automatic retry, compaction, or queued continuation left. It also coordinates with `@monotykamary/pi-retry` through Pi's shared extension event bus so that extension's delayed hidden retries do not produce intermediate notifications. 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.
8
8
 
9
9
  ## Install
10
10
 
@@ -64,7 +64,7 @@ The scrub overrides pi's built-in `bash` tool **by name** (extensions apply afte
64
64
 
65
65
  ## Requirements
66
66
 
67
- - pi ≥ 0.80 (uses the `spawnHook` tool option and `createBashToolDefinition` export).
67
+ - pi ≥ 0.80.7 (uses `agent_settled`, the shared extension event bus, the `spawnHook` tool option, and the `createBashToolDefinition` export).
68
68
  - localterm with `LOCALTERM=1` in the PTY environment (v0.7+). The scrub additionally needs localterm's secrets/processes policy files on the same machine.
69
69
 
70
70
  ## License
@@ -1,40 +1,85 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { AGENT_NOTIFY_MIN_ELAPSED_MS } from "../src/constants.js";
1
+ import type {
2
+ AgentEndEvent,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ AGENT_NOTIFY_MIN_ELAPSED_MS,
8
+ PI_RETRY_CANCELLED_EVENT,
9
+ PI_RETRY_COMPLETED_EVENT,
10
+ PI_RETRY_STARTED_EVENT,
11
+ } from "../src/constants.js";
3
12
  import { extractAssistantExcerpt, formatAgentEndBody } from "../src/utils/agent-notify-body.js";
13
+ import { retryEventId } from "../src/utils/retry-event-id.js";
4
14
  import { buildOsc9Sequence } from "../src/utils/osc-sequence.js";
5
15
 
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
16
  export const registerAgentNotify = (pi: ExtensionAPI): void => {
17
+ let activeRetryId: number | undefined;
18
+ let latestMessages: AgentEndEvent["messages"] = [];
19
+ let settledContext: ExtensionContext | undefined;
23
20
  let turnStartedAt: number | undefined;
21
+ let hasCurrentRunSettled = false;
24
22
 
25
- pi.on("agent_start", () => {
26
- turnStartedAt = Date.now();
27
- });
23
+ const resetNotificationState = (): void => {
24
+ latestMessages = [];
25
+ settledContext = undefined;
26
+ turnStartedAt = undefined;
27
+ hasCurrentRunSettled = false;
28
+ };
29
+
30
+ const emitNotification = (): void => {
31
+ if (!hasCurrentRunSettled || settledContext === undefined || turnStartedAt === undefined)
32
+ return;
28
33
 
29
- pi.on("agent_end", (event, ctx) => {
30
- if (ctx.mode !== "tui" || turnStartedAt === undefined) return;
31
34
  const elapsedMs = Date.now() - turnStartedAt;
32
- if (elapsedMs < AGENT_NOTIFY_MIN_ELAPSED_MS) return;
35
+ const context = settledContext;
36
+ const messages = latestMessages;
37
+ resetNotificationState();
38
+
39
+ if (context.mode !== "tui" || elapsedMs < AGENT_NOTIFY_MIN_ELAPSED_MS) return;
40
+
33
41
  const body = formatAgentEndBody(
34
42
  elapsedMs,
35
43
  pi.getSessionName(),
36
- extractAssistantExcerpt(event.messages),
44
+ extractAssistantExcerpt(messages),
37
45
  );
38
46
  process.stdout.write(buildOsc9Sequence(body));
47
+ };
48
+
49
+ const unsubscribeRetryStarted = pi.events.on(PI_RETRY_STARTED_EVENT, (event) => {
50
+ activeRetryId = retryEventId(event);
51
+ });
52
+ const unsubscribeRetryCompleted = pi.events.on(PI_RETRY_COMPLETED_EVENT, (event) => {
53
+ if (retryEventId(event) !== activeRetryId) return;
54
+ activeRetryId = undefined;
55
+ emitNotification();
56
+ });
57
+ const unsubscribeRetryCancelled = pi.events.on(PI_RETRY_CANCELLED_EVENT, (event) => {
58
+ if (retryEventId(event) !== activeRetryId) return;
59
+ activeRetryId = undefined;
60
+ resetNotificationState();
61
+ });
62
+
63
+ pi.on("agent_start", () => {
64
+ turnStartedAt ??= Date.now();
65
+ hasCurrentRunSettled = false;
66
+ settledContext = undefined;
67
+ });
68
+
69
+ pi.on("agent_end", (event) => {
70
+ latestMessages = event.messages;
71
+ });
72
+
73
+ pi.on("agent_settled", (_event, context) => {
74
+ hasCurrentRunSettled = true;
75
+ settledContext = context;
76
+ if (activeRetryId === undefined) emitNotification();
77
+ });
78
+
79
+ pi.on("session_shutdown", () => {
80
+ unsubscribeRetryStarted();
81
+ unsubscribeRetryCompleted();
82
+ unsubscribeRetryCancelled();
83
+ resetNotificationState();
39
84
  });
40
85
  };
@@ -10,9 +10,9 @@ import { enableKittyImages } from "./kitty-images.js";
10
10
  // detect, (2) scrubs localterm-managed secret env vars from the agent's
11
11
  // bash-tool children so a generated command can't read keys the shim injected
12
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
13
+ // agent_settled (reusing localterm's existing OSC 9 -> browser notification
14
14
  // pipeline) so a user who stepped away from the pi tab learns the agent
15
- // finished.
15
+ // finished, including after retries and queued continuations.
16
16
  export default (pi: ExtensionAPI): void => {
17
17
  if (process.env.LOCALTERM !== "1") return;
18
18
  enableKittyImages();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-localterm",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
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",
@@ -29,8 +29,8 @@
29
29
  "access": "public"
30
30
  },
31
31
  "devDependencies": {
32
- "@earendil-works/pi-coding-agent": "^0.80.6",
33
- "@earendil-works/pi-tui": "^0.80.6",
32
+ "@earendil-works/pi-coding-agent": "^0.80.10",
33
+ "@earendil-works/pi-tui": "^0.80.10",
34
34
  "@types/node": "^26.1.1",
35
35
  "typescript": "^7.0.2",
36
36
  "vite-plus": "^0.2.4"
package/src/constants.ts CHANGED
@@ -35,6 +35,10 @@ export const NOTIFICATION_MAX_LENGTH = 1024;
35
35
  // tab. Tunable: lower to notify sooner, raise to stay quieter while focused.
36
36
  export const AGENT_NOTIFY_MIN_ELAPSED_MS = 30_000;
37
37
 
38
+ export const PI_RETRY_STARTED_EVENT = "pi-retry:started";
39
+ export const PI_RETRY_COMPLETED_EVENT = "pi-retry:completed";
40
+ export const PI_RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
41
+
38
42
  // Cap on the assistant-response excerpt carried by the "pi finished"
39
43
  // notification body, counted in UTF-16 code units after whitespace is
40
44
  // collapsed. Long enough to surface a sentence or two of the agent's final
@@ -0,0 +1,4 @@
1
+ export const retryEventId = (event: unknown): number | undefined => {
2
+ if (typeof event !== "object" || event === null || !("retryId" in event)) return undefined;
3
+ return typeof event.retryId === "number" ? event.retryId : undefined;
4
+ };