@pi-archimedes/notify 2.10.4 → 2.11.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Step away without losing track.**
4
4
 
5
- Leave the terminal for a coffee. When the work has actually settled — or something needs your decision — notify tells you, once, after a short delay. Type anything and the pending alert is cancelled; there's no inactivity scrutiny, no focus tracking. Keystrokes are the only signal.
5
+ Leave the terminal for a coffee. When the work has actually settled — or something needs your decision — notify tells you, once, after a short delay. Press a key and the pending alert is cancelled; there's no inactivity scrutiny, no focus tracking. Keystrokes are the only signal — a terminal answering Pi's own questions (colour palette, device attributes, cell size) is not one.
6
6
 
7
7
  ## Install
8
8
 
@@ -31,7 +31,7 @@ After installing Pi, choose one installation command above, then `cd` into your
31
31
  - **Settled runs** — on Pi's `agent_settled` event: the run has fully settled (no automatic retry, compaction, or queued continuation still to fire), not merely "a turn ended".
32
32
  - **Blocking prompts** — on `ui_prompt_start`: any extension prompt is waiting on you (a tabbed ask, a sudo password or command-confirm prompt) — direct **or** subagent-relayed, since the event fires in the parent process.
33
33
 
34
- The alert fires after a fixed `delayMs` (30 s by default) from the trigger. It is a delay, not an idle timer: nothing measures how long you've been reading. Any input in the terminal cancels pending alerts immediately, a new agent run cancels them, and a prompt that closes without you typing (say, a confirm dialog that times out and auto-declines) cancels its own timer — so a long-gone question can't ring.
34
+ The alert fires after a fixed `delayMs` (30 s by default) from the trigger. It is a delay, not an idle timer: nothing measures how long you've been reading. A keystroke in the terminal cancels a pending alert immediately — and only a keystroke: pi-tui's decoder decides, so protocol traffic that reaches the listener (a device-attributes reply, a cell-size report, focus and mouse sequences) cannot silently swallow an alert nobody dismissed. Anything you actually typed counts, including the characters the decoder has no name for — an accented key or an IME commit on a terminal that doesn't speak the Kitty keyboard protocol. A new agent run cancels pending alerts, whichever kind of run start it was, and a prompt that closes without you typing (say, a confirm dialog that times out and auto-declines) cancels its own timer — so a long-gone question can't ring.
35
35
 
36
36
  ## Terminal delivery
37
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/notify",
3
- "version": "2.10.4",
3
+ "version": "2.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -18,15 +18,15 @@
18
18
  ".": "./src/index.ts"
19
19
  },
20
20
  "dependencies": {
21
- "@pi-archimedes/core": "2.10.4"
21
+ "@pi-archimedes/core": "2.11.0"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-coding-agent": ">=0.84.4",
25
- "@earendil-works/pi-tui": ">=0.1.0"
25
+ "@earendil-works/pi-tui": ">=0.84.4"
26
26
  },
27
27
  "devDependencies": {
28
- "@earendil-works/pi-coding-agent": "^0.87.0",
29
- "@earendil-works/pi-tui": "^0.87.0",
28
+ "@earendil-works/pi-coding-agent": "1.2.0",
29
+ "@earendil-works/pi-tui": "1.2.0",
30
30
  "typescript": "^6.0.0"
31
31
  },
32
32
  "pi": {
@@ -0,0 +1,306 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // ── What this pins ──────────────────────────────────────────────────────────
4
+ //
5
+ // Two cancel paths, both of which pi 1.x changed underneath us:
6
+ //
7
+ // 1. `before_agent_start`. pi 1.2.0 made the event a union on `messageType`
8
+ // ("user" for a submitted prompt, "custom" for `pi.sendMessage(…,
9
+ // { triggerTurn: true })` fired while idle) and fires it for both. A run
10
+ // that is *starting* makes a pending "Task complete — waiting for input"
11
+ // stale either way, so the handler stays unconditional and this file pins
12
+ // both members of the union.
13
+ //
14
+ // 2. Terminal input. The cancel used to be "any byte that reaches the
15
+ // listener". pi now puts far more protocol traffic through the terminal —
16
+ // and `TuiBase.handleTerminalInput()` (pi-tui `dist/tui.js`) forwards some
17
+ // of it to extension listeners and swallows the rest:
18
+ // - OSC 10/11/4 colour replies and the colour-scheme report are consumed
19
+ // *before* listeners run, so a palette reply can never reach notify;
20
+ // - a primary-device-attributes reply (`ESC [ ? … c`) is consumed before
21
+ // listeners only while a colour query is pending, so a late or
22
+ // unsolicited one *is* forwarded;
23
+ // - a cell-size report (`ESC [ 6 ; h ; w t`) is consumed only *after*
24
+ // listeners, so it always reaches them.
25
+ // So a reply is not a keystroke: only input pi-tui decodes to a key
26
+ // (`parseKey`) cancels.
27
+
28
+ vi.mock("@pi-archimedes/core/settings-io", () => ({
29
+ loadConfig: vi.fn(),
30
+ saveConfig: vi.fn(),
31
+ }));
32
+
33
+ import { loadConfig } from "@pi-archimedes/core/settings-io";
34
+ import { DEFAULT_NOTIFY_CONFIG, registerNotify } from "./index.js";
35
+
36
+ const DELAY_MS = 1_000;
37
+
38
+ /** The OSC 777 fallback payload `notify()` writes for each trigger. */
39
+ const TASK_COMPLETE_OSC = "\x1b]777;notify;Pi;Task complete — waiting for input\x07";
40
+ const QUESTION_OSC = "\x1b]777;notify;Pi;A question needs your answer\x07";
41
+
42
+ interface Harness {
43
+ /** Fire a pi event at the handlers `registerNotify` subscribed. */
44
+ emit(event: string, payload?: unknown): void;
45
+ /** Feed one chunk of raw terminal input to the registered listener. */
46
+ terminalInput(data: string): void;
47
+ /** Notification payloads written to stderr so far. */
48
+ written(): string[];
49
+ /** Let the pending notification's delay elapse (and then some). */
50
+ waitPastDelay(): void;
51
+ }
52
+
53
+ function setup(): Harness {
54
+ vi.mocked(loadConfig).mockReturnValue({ ...DEFAULT_NOTIFY_CONFIG, delayMs: DELAY_MS });
55
+ // Fall through to the OSC 777 dispatcher: no terminal-specific branch.
56
+ vi.stubEnv("WT_SESSION", "");
57
+ vi.stubEnv("KITTY_WINDOW_ID", "");
58
+ vi.stubEnv("TERM_PROGRAM", "");
59
+ vi.stubEnv("ITERM_SESSION_ID", "");
60
+ vi.stubEnv("TMUX", "");
61
+ const write = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
62
+
63
+ const handlers: Record<string, Array<(payload: unknown, ctx: unknown) => void>> = {};
64
+ let terminalListener: ((data: string) => unknown) | undefined;
65
+
66
+ const pi = {
67
+ on: vi.fn((event: string, handler: (payload: unknown, ctx: unknown) => void) => {
68
+ (handlers[event] ??= []).push(handler);
69
+ }),
70
+ } as unknown as Parameters<typeof registerNotify>[0];
71
+
72
+ registerNotify(pi);
73
+
74
+ const ctx = {
75
+ hasUI: true,
76
+ ui: {
77
+ onTerminalInput: vi.fn((handler: (data: string) => unknown) => {
78
+ terminalListener = handler;
79
+ return () => {
80
+ terminalListener = undefined;
81
+ };
82
+ }),
83
+ },
84
+ };
85
+ for (const handler of handlers["session_start"] ?? []) handler(undefined, ctx);
86
+
87
+ return {
88
+ emit(event, payload) {
89
+ for (const handler of handlers[event] ?? []) handler(payload ?? { type: event }, ctx);
90
+ },
91
+ terminalInput(data) {
92
+ terminalListener?.(data);
93
+ },
94
+ written() {
95
+ return write.mock.calls.map((call) => String(call[0]));
96
+ },
97
+ waitPastDelay() {
98
+ vi.advanceTimersByTime(DELAY_MS * 2);
99
+ },
100
+ };
101
+ }
102
+
103
+ beforeEach(() => {
104
+ vi.useFakeTimers();
105
+ });
106
+
107
+ afterEach(() => {
108
+ vi.useRealTimers();
109
+ vi.restoreAllMocks();
110
+ vi.unstubAllEnvs();
111
+ vi.clearAllMocks();
112
+ });
113
+
114
+ describe("notify cancel-on-input under pi 1.x terminal traffic", () => {
115
+ it("does not cancel on a forwarded primary-device-attributes reply", () => {
116
+ const harness = setup();
117
+
118
+ harness.emit("agent_settled");
119
+ // The DA1 reply shape pi-tui forwards to extension listeners.
120
+ harness.terminalInput("\x1b[?62;1;9c");
121
+ harness.waitPastDelay();
122
+
123
+ expect(harness.written()).toContain(TASK_COMPLETE_OSC);
124
+ });
125
+
126
+ it("does not cancel on a cell-size report", () => {
127
+ const harness = setup();
128
+
129
+ harness.emit("agent_settled");
130
+ // ESC [ 6 ; height ; width t — consumed by pi-tui only after listeners.
131
+ harness.terminalInput("\x1b[6;24;80t");
132
+ harness.waitPastDelay();
133
+
134
+ expect(harness.written()).toContain(TASK_COMPLETE_OSC);
135
+ });
136
+
137
+ it("does not cancel on a colour reply or a colour-scheme report", () => {
138
+ const harness = setup();
139
+
140
+ harness.emit("agent_settled");
141
+ harness.terminalInput("\x1b]10;rgb:ffff/ffff/ffff\x07");
142
+ harness.terminalInput("\x1b]11;rgb:0000/0000/0000\x1b\\");
143
+ harness.terminalInput("\x1b]4;1;rgb:ff00/0000/0000\x1b\\");
144
+ harness.terminalInput("\x1b[?u"); // the Kitty keyboard-protocol query reply
145
+ harness.waitPastDelay();
146
+
147
+ expect(harness.written()).toContain(TASK_COMPLETE_OSC);
148
+ });
149
+
150
+ it("does not cancel on focus, mouse or bracketed-paste traffic", () => {
151
+ const harness = setup();
152
+
153
+ harness.emit("agent_settled");
154
+ // None of these is typing, and pi-tui passes all of them to listeners: focus
155
+ // in/out, an SGR mouse press, and the bracketed-paste marker.
156
+ harness.terminalInput("\x1b[I");
157
+ harness.terminalInput("\x1b[O");
158
+ harness.terminalInput("\x1b[<0;10;10M");
159
+ harness.terminalInput("\x1b[200~");
160
+ harness.waitPastDelay();
161
+
162
+ expect(harness.written()).toContain(TASK_COMPLETE_OSC);
163
+ });
164
+
165
+ it("cancels on a plain keystroke", () => {
166
+ const harness = setup();
167
+
168
+ harness.emit("agent_settled");
169
+ harness.terminalInput("a");
170
+ harness.waitPastDelay();
171
+
172
+ expect(harness.written()).toEqual([]);
173
+ });
174
+
175
+ // What `parseKey()` names is not the whole set of keystrokes. pi-tui's
176
+ // `StdinBuffer` splits batched input into single sequences before listeners
177
+ // run, so a typed character reaches us on its own — but the decoder only
178
+ // resolves ASCII bytes and escape sequences. A character outside ASCII
179
+ // arrives as itself (`é` is the two bytes C3 A9 on any terminal that did not
180
+ // take the Kitty keyboard protocol, which is Terminal.app, VTE, xterm and
181
+ // tmux), `parseKey()` has no name for it, and the keystroke would be missed:
182
+ // the alert fires at someone sitting at the keyboard, typing an accented
183
+ // letter. Under Kitty the same key arrives as CSI-u and does decode, so this
184
+ // is exactly the legacy-terminal path.
185
+ it("cancels on a typed character pi-tui's decoder does not name", () => {
186
+ const harness = setup();
187
+
188
+ harness.emit("agent_settled");
189
+ harness.terminalInput("é"); // one key press, no Kitty protocol
190
+ harness.waitPastDelay();
191
+
192
+ expect(harness.written()).toEqual([]);
193
+ });
194
+
195
+ it("cancels on a typed CJK character", () => {
196
+ const harness = setup();
197
+
198
+ harness.emit("agent_settled");
199
+ harness.terminalInput("中");
200
+ harness.waitPastDelay();
201
+
202
+ expect(harness.written()).toEqual([]);
203
+ });
204
+
205
+ it("does not cancel on the ASCII tail of a reply split across reads", () => {
206
+ const harness = setup();
207
+
208
+ // A reply whose ESC header pi-tui consumed can leave a bare tail (digits
209
+ // and semicolons) to be flushed as input. It is printable ASCII with no
210
+ // escape byte, so "is it text?" is the wrong question to ask — only a
211
+ // character that could only have come from a key press cancels.
212
+ harness.emit("agent_settled");
213
+ harness.terminalInput("62;1;9c");
214
+ harness.waitPastDelay();
215
+
216
+ expect(harness.written()).toContain(TASK_COMPLETE_OSC);
217
+ });
218
+
219
+ it("cancels on a Kitty-protocol key press and on its release", () => {
220
+ const harness = setup();
221
+
222
+ harness.emit("agent_settled");
223
+ harness.terminalInput("\x1b[97:1u"); // CSI-u press for 'a'
224
+ harness.waitPastDelay();
225
+ expect(harness.written()).toEqual([]);
226
+
227
+ harness.emit("agent_settled");
228
+ harness.terminalInput("\x1b[97:3u"); // the matching release
229
+ harness.waitPastDelay();
230
+ expect(harness.written()).toEqual([]);
231
+ });
232
+
233
+ it("fires when the terminal stays quiet", () => {
234
+ const harness = setup();
235
+
236
+ harness.emit("agent_settled");
237
+ harness.waitPastDelay();
238
+
239
+ expect(harness.written()).toEqual([TASK_COMPLETE_OSC]);
240
+ });
241
+ });
242
+
243
+ describe("notify cancel-on-run-start (pi 1.2.0 before_agent_start union)", () => {
244
+ const base = {
245
+ type: "before_agent_start",
246
+ systemPrompt: "prompt",
247
+ systemPromptOptions: {},
248
+ };
249
+
250
+ it("cancels a pending notification for a user-prompt run", () => {
251
+ const harness = setup();
252
+
253
+ harness.emit("agent_settled");
254
+ harness.emit("before_agent_start", { ...base, messageType: "user", prompt: "hi" });
255
+ harness.waitPastDelay();
256
+
257
+ expect(harness.written()).toEqual([]);
258
+ });
259
+
260
+ it("cancels a pending notification for an idle custom message with triggerTurn", () => {
261
+ const harness = setup();
262
+
263
+ harness.emit("agent_settled");
264
+ harness.emit("before_agent_start", {
265
+ ...base,
266
+ messageType: "custom",
267
+ message: { role: "custom", customType: "note", content: "hello", display: true },
268
+ });
269
+ harness.waitPastDelay();
270
+
271
+ expect(harness.written()).toEqual([]);
272
+ });
273
+
274
+ it("cancels on agent_start as before", () => {
275
+ const harness = setup();
276
+
277
+ harness.emit("agent_settled");
278
+ harness.emit("agent_start");
279
+ harness.waitPastDelay();
280
+
281
+ expect(harness.written()).toEqual([]);
282
+ });
283
+ });
284
+
285
+ describe("notify ui_prompt_end scoping (ADR 0013)", () => {
286
+ it("cancels a pending question notification when the prompt closes", () => {
287
+ const harness = setup();
288
+
289
+ harness.emit("ui_prompt_start");
290
+ harness.emit("ui_prompt_end");
291
+ harness.waitPastDelay();
292
+
293
+ expect(harness.written()).toEqual([]);
294
+ });
295
+
296
+ it("leaves a pending task-complete notification alone", () => {
297
+ const harness = setup();
298
+
299
+ harness.emit("agent_settled");
300
+ harness.emit("ui_prompt_end");
301
+ harness.waitPastDelay();
302
+
303
+ expect(harness.written()).toEqual([TASK_COMPLETE_OSC]);
304
+ expect(harness.written()).not.toContain(QUESTION_OSC);
305
+ });
306
+ });
package/src/index.ts CHANGED
@@ -1,9 +1,21 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { SettingItem } from "@earendil-works/pi-tui";
3
+ import { parseKey } from "@earendil-works/pi-tui";
3
4
  import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
4
5
  import { execFile } from "node:child_process";
5
6
 
6
7
  // ── Trigger constants ────────────────────────────────────────────────────────
8
+ //
9
+ // Relation to pi's own OSC 7501 program status (pi >= 1.1.0):
10
+ // pi's ProgramStatusReporter already reports this session's state to the
11
+ // terminal — `done` when a run settles and `blocked` when a dialog opens
12
+ // (`pi-coding-agent/dist/modes/interactive/program-status-reporter.js`,
13
+ // encoded by pi-tui's `formatProgramStatus`). The two triggers below map onto
14
+ // it one-for-one: AGENT_SETTLED <-> `done`, UI_PROMPT <-> `blocked`. We emit
15
+ // nothing 7501-related; what this package adds on top of that report is the
16
+ // DELAYED desktop notification (OSC 777/9/99, Windows toast, tmux DCS wrap)
17
+ // and the cancellation rules below — pi reports state changes immediately and
18
+ // to the terminal only. Full comparison: docs/features/notify.md.
7
19
 
8
20
  const TRIGGER = {
9
21
  AGENT_SETTLED: "agent_settled",
@@ -39,6 +51,58 @@ export function saveNotifyConfig(config: NotifyConfig): void {
39
51
 
40
52
  // ── Terminal detection helpers ──────────────────────────────────────────────
41
53
 
54
+ /**
55
+ * Did the user press a key, or is this the terminal answering a question?
56
+ *
57
+ * pi-tui decodes input for us, and the decode is the test: `parseKey()` returns
58
+ * a key id for a keystroke (legacy byte, escape sequence, Kitty CSI-u press,
59
+ * repeat **or** release) and `undefined` for everything else. Cancel on the
60
+ * keystrokes only.
61
+ *
62
+ * The rule used to be "any byte that reaches the listener", which pi 1.x makes
63
+ * wrong, because far more protocol traffic goes through the terminal now and
64
+ * `TuiBase.handleTerminalInput()` (pi-tui `dist/tui.js:687-712`) forwards some
65
+ * of it to extension listeners and swallows the rest:
66
+ *
67
+ * - The colour-scheme report is consumed before listeners, unconditionally. The
68
+ * OSC 10/11/4 colour replies go through the same pending-query branch as the
69
+ * device-attributes reply below, so during pi's palette query — at startup and
70
+ * on every theme switch, when that branch is armed — they are swallowed there;
71
+ * a reply that turns up with no query outstanding is forwarded like any other
72
+ * byte.
73
+ * - A primary-device-attributes reply (`ESC [ ? … c`) is swallowed only while a
74
+ * colour query is pending (`tui.js`, `pendingTerminalColorQueries`), so a
75
+ * late or unsolicited one — the shape is `\x1b[?62;1;9c` — **is** forwarded.
76
+ * - A cell-size report (`ESC [ 6 ; height ; width t`) is consumed only *after*
77
+ * listeners, so it always reaches them.
78
+ * - Focus in/out, mouse reporting, bracketed-paste markers and the Kitty
79
+ * protocol query reply reach listeners too, and none of them is typing.
80
+ *
81
+ * So a reply used to be able to cancel an alert nobody asked for. A mouse event
82
+ * no longer counts either — notify's contract is keystrokes (see README), and
83
+ * `parseKey()` is what says whether one happened.
84
+ *
85
+ * The decoder is the test, but not the whole test: it names ASCII bytes and
86
+ * escape sequences, and nothing else. A character outside ASCII is delivered as
87
+ * itself — `é` is the two bytes C3 A9 on every terminal that did not take the
88
+ * Kitty keyboard protocol (Terminal.app, VTE/GNOME Terminal, xterm, tmux) — and
89
+ * `parseKey()` returns `undefined` for it. Treating that as "not a keystroke"
90
+ * would fire an alert at someone standing at the keyboard typing an accented
91
+ * letter, which is the failure this handler exists to prevent, so a printable
92
+ * character with no control byte in it counts as typing too. The test stays
93
+ * narrow on purpose: the `é`-shaped answer cannot mistake protocol traffic for
94
+ * a key press, because every reply pi-tui forwards opens with `ESC`, and the
95
+ * only other printable bytes a listener can see are the *tail* of a reply split
96
+ * across two reads (`62;1;9c`) — printable ASCII, still no key press.
97
+ */
98
+ const TYPED_NON_ASCII = /[\u00a0-\uffff]/;
99
+ const CONTROL_BYTE = /[\u0000-\u001f\u007f-\u009f]/;
100
+
101
+ function isKeystroke(data: string): boolean {
102
+ if (parseKey(data) !== undefined) return true;
103
+ return TYPED_NON_ASCII.test(data) && !CONTROL_BYTE.test(data);
104
+ }
105
+
42
106
  /** Wrap an OSC sequence for tmux passthrough. */
43
107
  export function wrapForTmux(sequence: string): string {
44
108
  if (!process.env.TMUX) {
@@ -189,6 +253,13 @@ export function registerNotify(pi: ExtensionAPI): void {
189
253
  // parent process for direct and subagent-relayed prompts alike.
190
254
  pi.on("ui_prompt_start", (_event) => scheduleNotify(TRIGGER.UI_PROMPT));
191
255
  pi.on("input", () => cancelPending());
256
+ // Any run that *starts* makes a pending alert stale, so this handler takes no
257
+ // filter. pi 1.2.0 made `before_agent_start` a union on `messageType` —
258
+ // `"user"` for a submitted prompt, RPC prompt or `pi.sendUserMessage()`, and
259
+ // `"custom"` for `pi.sendMessage(…, { triggerTurn: true })` fired while idle,
260
+ // which is new traffic that fires it too — and both members mean the same
261
+ // thing here: work is beginning, so "Task complete — waiting for input" is
262
+ // now a lie, whoever caused the run. Same reason `agent_start` cancels.
192
263
  pi.on("before_agent_start", () => cancelPending());
193
264
  pi.on("agent_start", () => cancelPending());
194
265
  // A prompt that closes without terminal input (e.g. a ctx.ui.confirm
@@ -201,11 +272,14 @@ export function registerNotify(pi: ExtensionAPI): void {
201
272
  }
202
273
  });
203
274
 
204
- // Listen for raw terminal keystrokes — cancel on any key press
275
+ // Listen for raw terminal keystrokes — cancel on any key press, and only on
276
+ // one: terminal protocol traffic is not a keystroke (see `isKeystroke`).
205
277
  let unsubTerminalInput: (() => void) | null = null;
206
278
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
207
- unsubTerminalInput = ctx.ui.onTerminalInput((_data) => {
208
- cancelPending();
279
+ unsubTerminalInput = ctx.ui.onTerminalInput((data) => {
280
+ if (isKeystroke(data)) {
281
+ cancelPending();
282
+ }
209
283
  });
210
284
  });
211
285