@raidou/pi-notify 0.6.0 → 0.7.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
  A notification extension for the [pi](https://github.com/earendil-works/pi-coding-agent) coding agent.
4
4
 
5
- `@raidou/pi-notify` fires a native desktop notification on idle, configured tool calls (e.g., `Tool call: ask_user`), and custom pi events (default: `permissions:ui_prompt`).
5
+ `@raidou/pi-notify` fires a native desktop notification on idle, tool calls (optional, e.g. `Tool call: ask_user`), user-facing UI prompts (via pi's `ui_prompt_start`, e.g. permission approvals or `select`/`confirm`/`input` dialogs, requires pi >= 0.85.0), and custom pi events.
6
6
 
7
7
  ## Installation
8
8
 
@@ -25,12 +25,11 @@ All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everyt
25
25
  {
26
26
  "piNotify": {
27
27
  "enabled": true, // master on/off switch (default: true)
28
- "notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
28
+ "notifyTools": [], // tools that trigger "Tool call" notifications (default: empty, i.e. no tool notifications)
29
29
  "tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
30
30
  "finished": true, // enable/disable "Idle" notification
31
31
  "events": {
32
- "permissions:ui_prompt": "Permission prompt", // custom event channel -> notification message
33
- "my:custom:event": "Custom event triggered", // add your own custom events
32
+ "my:custom:event": "Custom event triggered", // custom event channel -> notification message
34
33
  "other:event": false, // set to false to disable a specific event
35
34
  },
36
35
  "finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
@@ -42,11 +41,13 @@ All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everyt
42
41
 
43
42
  ## What triggers a notification
44
43
 
45
- | Event | Source | Default body |
46
- | ----------------- | ----------------------------------------------------------- | ------------------------------------------- |
47
- | **Finished** | `agent_settled` (pi idle, no active jobs) | `Idle` |
48
- | **Tool calls** | `tool_call` on tools in `notifyTools` | `Tool call: <toolName>` |
49
- | **Custom events** | Custom pi event channels (default: `permissions:ui_prompt`) | Customizable (default: `Permission prompt`) |
44
+ | Event | Source | Default body |
45
+ | ----------------- | --------------------------------------------------------------------- | --------------------------------- |
46
+ | **Finished** | `agent_settled` (pi idle, no active jobs) | `Idle` |
47
+ | **Tool calls** | `tool_call` on tools in `notifyTools` (default: none) | `Tool call: <toolName>` |
48
+ | **UI prompts** | `ui_prompt_start` (requires pi >= 0.85.0) | `Waiting: <kind>[ <title>]` |
49
+ | **Custom events** | Custom pi event channels configured in `events` | Customizable |
50
+ | **External API** | `pi.events.emit('pi-notify:notify', 'message')` from other extensions | The emitted message |
50
51
 
51
52
  ### Job tracking for background tasks
52
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi",
@@ -34,16 +34,17 @@
34
34
  "test:unit": "vitest run"
35
35
  },
36
36
  "dependencies": {
37
- "@earendil-works/pi-tui": "0.80.7",
38
37
  "emittery": "^2.0.0",
39
38
  "lodash-es": "^4.18.1",
40
39
  "node-notifier": "^10.0.1",
41
40
  "proper-lockfile": "^4.1.2"
42
41
  },
43
42
  "peerDependencies": {
44
- "@earendil-works/pi-coding-agent": ">=0.79.0"
43
+ "@earendil-works/pi-coding-agent": ">=0.85.0",
44
+ "@earendil-works/pi-tui": ">=0.85.0"
45
45
  },
46
46
  "devDependencies": {
47
+ "@earendil-works/pi-tui": "0.85.1",
47
48
  "@raidou/eslint-config-base": "^4.4.3",
48
49
  "@types/lodash-es": "^4.17.12",
49
50
  "@types/node": "^22.0.0",
package/src/config.ts CHANGED
@@ -28,20 +28,8 @@ export interface ResolvedNotifyConfig {
28
28
  readonly tmuxSymbol: string
29
29
  }
30
30
 
31
- /**
32
- * Default tool names that trigger notifications.
33
- *
34
- * Note: These are built-in pi tool names. If pi renames these tools, the default should be updated.
35
- * Source: @earendil-works/pi-coding-agent
36
- */
37
- const DEFAULT_NOTIFY_TOOLS = ['ask_user', 'ask_user_question'] as const
38
-
39
31
  const DEFAULT_TMUX_SYMBOL = '🔔'
40
32
 
41
- const DEFAULT_EVENTS: NotifyEventsConfig = {
42
- 'permissions:ui_prompt': 'Permission prompt',
43
- }
44
-
45
33
  const SETTINGS_PATH = join(getAgentDir(), 'settings.json')
46
34
 
47
35
  function readRawConfig(): NotifyConfig {
@@ -59,11 +47,10 @@ function readRawConfig(): NotifyConfig {
59
47
 
60
48
  export function loadConfig(): ResolvedNotifyConfig {
61
49
  const cfg = readRawConfig()
62
- const events = cfg.events ?? DEFAULT_EVENTS
63
50
  return {
64
51
  enabled: cfg.enabled ?? true,
65
- notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
66
- events,
52
+ notifyTools: new Set(cfg.notifyTools ?? []),
53
+ events: cfg.events ?? {},
67
54
  finished: cfg.finished ?? true,
68
55
  onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
69
56
  unfocusedActivityThresholdMs: Math.max(
@@ -171,10 +171,11 @@ describe('SessionStore', () => {
171
171
  )
172
172
  store.register(vi.fn())
173
173
 
174
- expect(emitSpy).toHaveBeenCalledTimes(4)
174
+ expect(emitSpy).toHaveBeenCalledTimes(5)
175
175
  expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
176
176
  expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
177
177
  expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
178
+ expect(emitSpy).toHaveBeenCalledWith('ui_prompt', expect.any(Function))
178
179
  expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
179
180
  expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
180
181
  })
@@ -347,7 +348,7 @@ describe('SessionStore', () => {
347
348
  nowSpy.mockRestore()
348
349
  })
349
350
 
350
- it('does not update state on tool emission outside notifyTools', async () => {
351
+ it('updates state immediately on event emission', async () => {
351
352
  await updateState(() => ({ version: 2, sessions: {} }))
352
353
  const pi = makeFakePi()
353
354
  const jobTracker = {
@@ -359,8 +360,8 @@ describe('SessionStore', () => {
359
360
  pi as unknown as ExtensionAPI,
360
361
  jobTracker,
361
362
  {
362
- notifyTools: new Set(['read']),
363
- events: {},
363
+ notifyTools: new Set(['bash']),
364
+ events: { 'permissions:ui_prompt': 'msg' },
364
365
  } as unknown as ResolvedNotifyConfig,
365
366
  )
366
367
  tracker.register(() => {})
@@ -374,20 +375,53 @@ describe('SessionStore', () => {
374
375
  })
375
376
  await new Promise((resolve) => setTimeout(resolve, 20))
376
377
 
377
- expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
378
+ pi.emitEvent('permissions:ui_prompt', {})
379
+ await new Promise((resolve) => setTimeout(resolve, 20))
378
380
 
379
- pi.emit('tool_call', {
380
- type: 'tool_call',
381
- toolCallId: 't1',
382
- toolName: 'grep',
381
+ const record = readState().sessions[String(process.pid)]
382
+ expect(record?.state).toBe('event:permissions:ui_prompt')
383
+ })
384
+
385
+ it('updates state immediately on ui_prompt emission', async () => {
386
+ await updateState(() => ({ version: 2, sessions: {} }))
387
+ const pi = makeFakePi()
388
+ const jobTracker = {
389
+ hasActiveJobs: false,
390
+ onStart: () => () => {},
391
+ onEnd: () => () => {},
392
+ } as unknown as JobTracker
393
+ const tracker = new StateTracker(
394
+ pi as unknown as ExtensionAPI,
395
+ jobTracker,
396
+ {
397
+ notifyTools: new Set([]),
398
+ events: {},
399
+ } as unknown as ResolvedNotifyConfig,
400
+ )
401
+ tracker.register(() => {})
402
+
403
+ const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
404
+ store.register(() => {})
405
+
406
+ pi.emitSessionStart({
407
+ cwd: META.cwd,
408
+ sessionManager: { getSessionId: () => SESSION_ID },
383
409
  })
384
410
  await new Promise((resolve) => setTimeout(resolve, 20))
385
411
 
412
+ pi.emit('ui_prompt_start', {
413
+ type: 'ui_prompt_start',
414
+ reason: 'ui_prompt',
415
+ kind: 'confirm',
416
+ })
417
+
418
+ await new Promise((resolve) => setTimeout(resolve, 20))
419
+
386
420
  const record = readState().sessions[String(process.pid)]
387
- expect(record?.state).toBe('idle')
421
+ expect(record?.state).toBe('ui_prompt:confirm')
388
422
  })
389
423
 
390
- it('updates state immediately on event emission', async () => {
424
+ it('does not update state on tool emission outside notifyTools', async () => {
391
425
  await updateState(() => ({ version: 2, sessions: {} }))
392
426
  const pi = makeFakePi()
393
427
  const jobTracker = {
@@ -399,8 +433,8 @@ describe('SessionStore', () => {
399
433
  pi as unknown as ExtensionAPI,
400
434
  jobTracker,
401
435
  {
402
- notifyTools: new Set(['bash']),
403
- events: { 'permissions:ui_prompt': 'msg' },
436
+ notifyTools: new Set(['read']),
437
+ events: {},
404
438
  } as unknown as ResolvedNotifyConfig,
405
439
  )
406
440
  tracker.register(() => {})
@@ -414,11 +448,17 @@ describe('SessionStore', () => {
414
448
  })
415
449
  await new Promise((resolve) => setTimeout(resolve, 20))
416
450
 
417
- pi.emitEvent('permissions:ui_prompt', {})
451
+ expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
452
+
453
+ pi.emit('tool_call', {
454
+ type: 'tool_call',
455
+ toolCallId: 't1',
456
+ toolName: 'grep',
457
+ })
418
458
  await new Promise((resolve) => setTimeout(resolve, 20))
419
459
 
420
460
  const record = readState().sessions[String(process.pid)]
421
- expect(record?.state).toBe('event:permissions:ui_prompt')
461
+ expect(record?.state).toBe('idle')
422
462
  })
423
463
 
424
464
  it('ignores tool and event emissions before session_start', async () => {
@@ -79,6 +79,9 @@ export class SessionStore extends Registrar {
79
79
  this.stateTracker.events.on('tool', async ({ data }) => {
80
80
  await this.saveSession(`tool_call:${data}`)
81
81
  }),
82
+ this.stateTracker.events.on('ui_prompt', async ({ data }) => {
83
+ await this.saveSession(`ui_prompt:${data}`)
84
+ }),
82
85
  this.stateTracker.events.on('event', async ({ data }) => {
83
86
  await this.saveSession(`event:${data}`)
84
87
  }),
@@ -54,7 +54,11 @@ export async function readSessions(): Promise<SessionRecord[]> {
54
54
  }
55
55
 
56
56
  export type SessionState =
57
- 'running' | 'idle' | `tool_call:${string}` | `event:${string}`
57
+ | 'running'
58
+ | 'idle'
59
+ | `tool_call:${string}`
60
+ | `event:${string}`
61
+ | `ui_prompt:${string}`
58
62
 
59
63
  export interface SessionRecord {
60
64
  pid: number
@@ -112,8 +116,12 @@ function parseSessionRecord(value: unknown): SessionRecord | undefined {
112
116
 
113
117
  function isActivityState(
114
118
  value: string,
115
- ): value is `tool_call:${string}` | `event:${string}` {
116
- return value.startsWith('tool_call:') || value.startsWith('event:')
119
+ ): value is `tool_call:${string}` | `event:${string}` | `ui_prompt:${string}` {
120
+ return (
121
+ value.startsWith('tool_call:') ||
122
+ value.startsWith('event:') ||
123
+ value.startsWith('ui_prompt:')
124
+ )
117
125
  }
118
126
 
119
127
  function isSessionState(value: unknown): value is SessionState {
@@ -8,6 +8,7 @@ import { Dashboard } from './dashboard.js'
8
8
  const theme = {
9
9
  fg: (color: ThemeColor, text: string) => `\x1b[90m${text}\x1b[0m`,
10
10
  bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
11
+ underline: (text: string) => `\x1b[4m${text}\x1b[0m`,
11
12
  } as unknown as Theme
12
13
 
13
14
  const ANSI_RE =
@@ -89,7 +89,7 @@ const BASE_CONFIG: ResolvedNotifyConfig = {
89
89
  enabled: true,
90
90
  notifyTools: new Set(['bash', 'read']),
91
91
  events: {
92
- 'permissions:ui_prompt': 'msg',
92
+ 'my:custom:event': 'msg',
93
93
  'disabled:channel': false,
94
94
  },
95
95
  finished: true,
@@ -171,6 +171,19 @@ describe('StateTracker', () => {
171
171
  expect(states).toEqual(['running'])
172
172
  })
173
173
 
174
+ it('re-emits running after a notified custom event resets the state', async () => {
175
+ const pi = makeFakePi()
176
+ const { states } = makeTracker(pi)
177
+
178
+ pi.emit('turn_start')
179
+ pi.emitEvent('my:custom:event', {})
180
+ pi.emit('turn_start')
181
+
182
+ await flush()
183
+
184
+ expect(states).toEqual(['running', 'running'])
185
+ })
186
+
174
187
  it('re-emits running after a notified tool call resets the state', async () => {
175
188
  const pi = makeFakePi()
176
189
  const { states } = makeTracker(pi)
@@ -220,12 +233,12 @@ describe('StateTracker', () => {
220
233
  events.push(event.data)
221
234
  })
222
235
 
223
- pi.emitEvent('permissions:ui_prompt', {})
236
+ pi.emitEvent('my:custom:event', {})
224
237
  pi.emitEvent('disabled:channel', {})
225
238
 
226
239
  await flush()
227
240
 
228
- expect(events).toEqual(['permissions:ui_prompt'])
241
+ expect(events).toEqual(['my:custom:event'])
229
242
  })
230
243
 
231
244
  it('unsubscribes from channel events on stop', async () => {
@@ -237,7 +250,7 @@ describe('StateTracker', () => {
237
250
  })
238
251
 
239
252
  tracker.stop()
240
- pi.emitEvent('permissions:ui_prompt', {})
253
+ pi.emitEvent('my:custom:event', {})
241
254
 
242
255
  await flush()
243
256
 
@@ -262,7 +275,7 @@ describe('StateTracker', () => {
262
275
  const pi = makeFakePi()
263
276
  const { bodies } = makeTracker(pi)
264
277
 
265
- pi.emitEvent('permissions:ui_prompt', {})
278
+ pi.emitEvent('my:custom:event', {})
266
279
 
267
280
  expect(bodies).toEqual(['msg'])
268
281
  })
@@ -297,6 +310,56 @@ describe('StateTracker', () => {
297
310
  expect(bodies).toEqual(['custom payload'])
298
311
  })
299
312
 
313
+ it('notifies on ui_prompt_start with kind and title', () => {
314
+ const pi = makeFakePi()
315
+ const { bodies } = makeTracker(pi)
316
+
317
+ pi.emit('ui_prompt_start', {
318
+ type: 'ui_prompt_start',
319
+ reason: 'ui_prompt',
320
+ kind: 'confirm',
321
+ title: 'Apply changes?',
322
+ })
323
+
324
+ expect(bodies).toEqual(['Waiting: Confirm — Apply changes?'])
325
+ })
326
+
327
+ it('notifies on ui_prompt_start without a title', () => {
328
+ const pi = makeFakePi()
329
+ const { bodies } = makeTracker(pi)
330
+
331
+ pi.emit('ui_prompt_start', {
332
+ type: 'ui_prompt_start',
333
+ reason: 'ui_prompt',
334
+ kind: 'select',
335
+ })
336
+
337
+ expect(bodies).toEqual(['Waiting: Select'])
338
+ })
339
+
340
+ it('emits ui_prompt with the raw kind and resets running', async () => {
341
+ const pi = makeFakePi()
342
+ const { tracker, states } = makeTracker(pi)
343
+ const kinds: string[] = []
344
+ tracker.events.on('ui_prompt', (event) => {
345
+ kinds.push(event.data)
346
+ })
347
+
348
+ pi.emit('turn_start')
349
+ pi.emit('ui_prompt_start', {
350
+ type: 'ui_prompt_start',
351
+ reason: 'ui_prompt',
352
+ kind: 'confirm',
353
+ title: 'Apply changes?',
354
+ })
355
+ pi.emit('turn_start')
356
+
357
+ await flush()
358
+
359
+ expect(kinds).toEqual(['confirm'])
360
+ expect(states).toEqual(['running', 'running'])
361
+ })
362
+
300
363
  it('notifies for tools in notifyTools', () => {
301
364
  const pi = makeFakePi()
302
365
  const { bodies } = makeTracker(pi)
@@ -323,6 +386,22 @@ describe('StateTracker', () => {
323
386
  expect(bodies).toEqual([])
324
387
  })
325
388
 
389
+ it('does not notify when notifyTools is empty', () => {
390
+ const pi = makeFakePi()
391
+ const { bodies } = makeTracker(pi, {
392
+ ...BASE_CONFIG,
393
+ notifyTools: new Set([]),
394
+ })
395
+
396
+ pi.emit('tool_call', {
397
+ type: 'tool_call',
398
+ toolCallId: 't1',
399
+ toolName: 'bash',
400
+ })
401
+
402
+ expect(bodies).toEqual([])
403
+ })
404
+
326
405
  it('notifies Idle on idle when there was activity', async () => {
327
406
  const pi = makeFakePi()
328
407
  const { bodies } = makeTracker(pi)
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
1
+ import type {
2
+ ExtensionAPI,
3
+ UIPromptKind,
4
+ } from '@earendil-works/pi-coding-agent'
2
5
  import Emittery from 'emittery'
3
6
 
4
7
  import type { ResolvedNotifyConfig } from './config.js'
@@ -10,12 +13,27 @@ export const PI_NOTIFY_EVENT = 'pi-notify:notify'
10
13
 
11
14
  const IDLE_TIMEOUT_MS = 10000
12
15
 
16
+ const PROMPT_KIND_LABELS: Record<UIPromptKind, string> = {
17
+ select: 'Select',
18
+ confirm: 'Confirm',
19
+ input: 'Input',
20
+ editor: 'Editor',
21
+ custom: 'Prompt',
22
+ }
23
+
24
+ function promptMessage(kind: UIPromptKind, title?: string): string {
25
+ const label = PROMPT_KIND_LABELS[kind]
26
+ if (!title) return `Waiting: ${label}`
27
+ return `Waiting: ${label} — ${title}`
28
+ }
29
+
13
30
  export class StateTracker extends Registrar {
14
31
  readonly events = new Emittery<{
15
32
  running: never
16
33
  idle: never
17
34
  tool: string
18
35
  event: string
36
+ ui_prompt: string
19
37
  }>()
20
38
 
21
39
  private readonly jobTracker: JobTracker
@@ -80,6 +98,14 @@ export class StateTracker extends Registrar {
80
98
  this.unsubscribes.push(customEventUnsub)
81
99
  }
82
100
 
101
+ private setupUiPrompt() {
102
+ this.pi.on('ui_prompt_start', (event) => {
103
+ this.notify(promptMessage(event.kind, event.title))
104
+ this.running = false
105
+ void this.events.emit('ui_prompt', event.kind)
106
+ })
107
+ }
108
+
83
109
  private setupToolCall() {
84
110
  this.pi.on('tool_call', (event) => {
85
111
  if (this.config.notifyTools.has(event.toolName)) {
@@ -95,6 +121,7 @@ export class StateTracker extends Registrar {
95
121
  this.notify = notify
96
122
 
97
123
  this.setupPiEvents()
124
+ this.setupUiPrompt()
98
125
  this.setupToolCall()
99
126
 
100
127
  this.pi.on('turn_start', () => {