@raidou/pi-notify 0.3.1 → 0.4.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
@@ -10,11 +10,33 @@ A notification extension for the [pi](https://github.com/earendil-works/pi-codin
10
10
  pi install npm:@raidou/pi-notify
11
11
  ```
12
12
 
13
- Or, for local development, add the repo path to your `~/.pi/agent/settings.json`:
13
+ Or, for local development
14
+
15
+ ```bash
16
+ cd path/to/pi-notify
17
+ pi install .
18
+ ```
19
+
20
+ ## Configuration
21
+
22
+ All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everything is optional.
14
23
 
15
24
  ```jsonc
16
25
  {
17
- "extensions": ["/absolute/path/to/pi-notify"],
26
+ "piNotify": {
27
+ "enabled": true, // master on/off switch (default: true)
28
+ "notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
29
+ "tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
30
+ "finished": true, // enable/disable "Idle" notification
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
34
+ "other:event": false, // set to false to disable a specific event
35
+ },
36
+ "finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
37
+ "onlyNotifyWhenUnfocused": true, // only notify when user has been inactive
38
+ "unfocusedActivityThresholdSecs": 30, // seconds of inactivity before considering user "unfocused"
39
+ },
18
40
  }
19
41
  ```
20
42
 
@@ -44,42 +66,6 @@ function endBackgroundJob(jobId: string): void {
44
66
 
45
67
  Events are automatically cleaned up on `session_shutdown`.
46
68
 
47
- ## Configuration
48
-
49
- All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everything is optional.
50
-
51
- ```jsonc
52
- {
53
- "piNotify": {
54
- "enabled": true, // master on/off switch (default: true)
55
- "notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
56
- "tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
57
- "finished": true, // enable/disable "Idle" notification
58
- "events": {
59
- "permissions:ui_prompt": "Permission prompt", // custom event channel -> notification message
60
- "my:custom:event": "Custom event triggered", // add your own custom events
61
- },
62
- "finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
63
- "onlyNotifyWhenUnfocused": true, // only notify when user has been inactive
64
- "unfocusedActivityThresholdSecs": 30, // seconds of inactivity before considering user "unfocused"
65
- },
66
- }
67
- ```
68
-
69
- ### Disabling specific events
70
-
71
- To disable a specific event, set its message to an empty string:
72
-
73
- ```jsonc
74
- {
75
- "piNotify": {
76
- "events": {
77
- "permissions:ui_prompt": "", // disable permission notifications
78
- },
79
- },
80
- }
81
- ```
82
-
83
69
  ## Testing
84
70
 
85
71
  Run `/notify-test` inside pi to fire a test notification.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
- "pi-package",
7
6
  "pi",
7
+ "pi-package",
8
8
  "pi-extension",
9
9
  "pi-coding-agent",
10
10
  "notifications",
@@ -28,13 +28,16 @@
28
28
  },
29
29
  "main": "./src/index.ts",
30
30
  "scripts": {
31
- "test": "pnpm run test:types && pnpm run test:lint",
31
+ "test": "pnpm run test:types && pnpm run test:lint && pnpm run test:unit",
32
32
  "test:types": "tsc --noEmit",
33
- "test:lint": "eslint --fix ."
33
+ "test:lint": "eslint --fix .",
34
+ "test:unit": "vitest run"
34
35
  },
35
36
  "dependencies": {
36
37
  "@earendil-works/pi-tui": "0.80.7",
37
- "node-notifier": "^10.0.1"
38
+ "emittery": "^2.0.0",
39
+ "node-notifier": "^10.0.1",
40
+ "proper-lockfile": "^4.1.2"
38
41
  },
39
42
  "peerDependencies": {
40
43
  "@earendil-works/pi-coding-agent": ">=0.79.0"
@@ -42,8 +45,10 @@
42
45
  "devDependencies": {
43
46
  "@raidou/eslint-config-base": "^4.4.3",
44
47
  "@types/node": "^22.0.0",
48
+ "@types/proper-lockfile": "^4.1.4",
45
49
  "eslint": "^10.7.0",
46
50
  "prettier": "^3.9.5",
47
- "typescript": "^5.6.0"
51
+ "typescript": "^5.6.0",
52
+ "vitest": "^4.1.11"
48
53
  }
49
54
  }
package/src/config.ts CHANGED
@@ -3,8 +3,9 @@ import { join } from 'node:path'
3
3
 
4
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
5
 
6
+ // `false` disables the event
6
7
  interface NotifyEventsConfig {
7
- readonly [channel: string]: string
8
+ readonly [channel: string]: string | false
8
9
  }
9
10
 
10
11
  interface NotifyConfig {
@@ -1,24 +1,25 @@
1
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
-
1
+ import { Registrar } from '../shared/registrar.js'
3
2
  import { readSessions } from './state-store.js'
4
3
  import { createDashboard } from './ui.js'
5
4
 
6
- export class DashboardCommand {
7
- constructor(private readonly pi: ExtensionAPI) {}
8
-
9
- register(): void {
5
+ export class DashboardCommand extends Registrar {
6
+ protected override setup(): void {
10
7
  this.pi.registerCommand('notify-dashboard', {
11
8
  description: 'Show all pi sessions notify dashboard',
12
9
  handler: async (_args, ctx) => {
13
- const initialSessions = readSessions()
10
+ const initialSessions = await readSessions()
14
11
 
15
12
  await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
13
+ let closed = false
16
14
  const dashboard = createDashboard({
17
15
  tui,
18
16
  theme,
19
17
  initialSessions,
20
18
  onRefresh: async () => readSessions(),
21
19
  onClose: () => {
20
+ if (closed) return
21
+ closed = true
22
+ dashboard.dispose()
22
23
  done(undefined)
23
24
  },
24
25
  })
@@ -27,6 +28,7 @@ export class DashboardCommand {
27
28
  render: dashboard.render.bind(dashboard),
28
29
  handleInput: dashboard.handleInput.bind(dashboard),
29
30
  invalidate: dashboard.invalidate.bind(dashboard),
31
+ dispose: dashboard.dispose.bind(dashboard),
30
32
  }
31
33
  })
32
34
 
@@ -0,0 +1,290 @@
1
+ import { unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ import type { StateTracker } from '../state-tracker.js'
9
+ import { SessionStore } from './session-store.js'
10
+ import { readState, updateState } from './state-store.js'
11
+
12
+ type EventsListener = (payload: unknown) => void
13
+
14
+ interface FakeStateTracker {
15
+ events: {
16
+ on(event: string, listener: EventsListener): () => void
17
+ emit(event: string, data?: unknown): void
18
+ }
19
+ }
20
+
21
+ interface FakeSessionManager {
22
+ getSessionId(): string
23
+ }
24
+
25
+ interface FakePiContext {
26
+ cwd: string
27
+ sessionManager: FakeSessionManager
28
+ }
29
+
30
+ interface FakePi {
31
+ listeners: Map<string, Set<(...args: unknown[]) => void>>
32
+ eventListeners: Map<string, Set<EventsListener>>
33
+ on(event: string, listener: (...args: unknown[]) => void): void
34
+ events: { on(event: string, listener: EventsListener): () => void }
35
+ emit(event: string, ...args: unknown[]): void
36
+ emitEvent(event: string, payload: unknown): void
37
+ emitSessionStart(ctx: FakePiContext): void
38
+ }
39
+
40
+ function makeFakePi(): FakePi {
41
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
42
+ const eventListeners = new Map<string, Set<EventsListener>>()
43
+ const pi: FakePi = {
44
+ listeners,
45
+ eventListeners,
46
+ on(event, listener) {
47
+ let set = listeners.get(event)
48
+ if (!set) {
49
+ set = new Set()
50
+ listeners.set(event, set)
51
+ }
52
+ set.add(listener)
53
+ },
54
+ events: {
55
+ on(event, listener) {
56
+ let set = eventListeners.get(event)
57
+ if (!set) {
58
+ set = new Set()
59
+ eventListeners.set(event, set)
60
+ }
61
+ set.add(listener)
62
+ return () => {
63
+ set.delete(listener)
64
+ }
65
+ },
66
+ },
67
+ emit(event, ...args) {
68
+ const set = listeners.get(event)
69
+ if (!set) return
70
+ for (const listener of [...set]) listener(...args)
71
+ },
72
+ emitEvent(event, payload) {
73
+ const set = eventListeners.get(event)
74
+ if (!set) return
75
+ for (const listener of [...set]) listener(payload)
76
+ },
77
+ emitSessionStart(ctx) {
78
+ const set = listeners.get('session_start')
79
+ if (!set) return
80
+ for (const listener of [...set]) listener(undefined, ctx)
81
+ },
82
+ }
83
+ return pi
84
+ }
85
+
86
+ function makeFakeStateTracker(): FakeStateTracker {
87
+ const listeners = new Map<string, Set<EventsListener>>()
88
+ return {
89
+ events: {
90
+ on(event, listener) {
91
+ let set = listeners.get(event)
92
+ if (!set) {
93
+ set = new Set()
94
+ listeners.set(event, set)
95
+ }
96
+ set.add(listener)
97
+ return () => {
98
+ set.delete(listener)
99
+ }
100
+ },
101
+ emit(event, data?: unknown) {
102
+ const set = listeners.get(event)
103
+ if (!set) return
104
+ for (const listener of [...set]) listener({ data })
105
+ },
106
+ },
107
+ }
108
+ }
109
+
110
+ const SESSION_ID = 'test-session-123'
111
+ const META = {
112
+ cwd: '/test/project',
113
+ projectName: 'test-project',
114
+ }
115
+
116
+ describe('SessionStore', () => {
117
+ const testLockFile = join(getAgentDir(), 'pi-notify-test', 'state.json.lock')
118
+
119
+ beforeEach(async () => {
120
+ try {
121
+ unlinkSync(testLockFile)
122
+ } catch {
123
+ // ignore
124
+ }
125
+ await updateState(() => ({ version: 1, sessions: {} }))
126
+ })
127
+
128
+ afterEach(() => {
129
+ try {
130
+ unlinkSync(testLockFile)
131
+ } catch {
132
+ // ignore
133
+ }
134
+ })
135
+
136
+ it('creates instance and registers event listeners', () => {
137
+ const pi = makeFakePi()
138
+ const stateTracker = makeFakeStateTracker()
139
+ const emitSpy = vi.spyOn(stateTracker.events, 'on')
140
+ const piOnSpy = vi.spyOn(pi, 'on')
141
+
142
+ const store = new SessionStore(
143
+ pi as unknown as ExtensionAPI,
144
+ stateTracker as unknown as StateTracker,
145
+ )
146
+ store.register(vi.fn())
147
+
148
+ expect(emitSpy).toHaveBeenCalledTimes(2)
149
+ expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
150
+ expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
151
+ expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
152
+ })
153
+
154
+ it('handles session_start event without errors', () => {
155
+ const pi = makeFakePi()
156
+ const stateTracker = makeFakeStateTracker()
157
+
158
+ const store = new SessionStore(
159
+ pi as unknown as ExtensionAPI,
160
+ stateTracker as unknown as StateTracker,
161
+ )
162
+ store.register(vi.fn())
163
+
164
+ expect(() => {
165
+ pi.emitSessionStart({
166
+ cwd: META.cwd,
167
+ sessionManager: { getSessionId: () => SESSION_ID },
168
+ })
169
+ }).not.toThrow()
170
+ })
171
+
172
+ it('handles running event without errors', () => {
173
+ const pi = makeFakePi()
174
+ const stateTracker = makeFakeStateTracker()
175
+
176
+ const store = new SessionStore(
177
+ pi as unknown as ExtensionAPI,
178
+ stateTracker as unknown as StateTracker,
179
+ )
180
+ store.register(vi.fn())
181
+
182
+ pi.emitSessionStart({
183
+ cwd: META.cwd,
184
+ sessionManager: { getSessionId: () => SESSION_ID },
185
+ })
186
+
187
+ expect(() => {
188
+ stateTracker.events.emit('running')
189
+ }).not.toThrow()
190
+ })
191
+
192
+ it('handles idle event without errors', () => {
193
+ const pi = makeFakePi()
194
+ const stateTracker = makeFakeStateTracker()
195
+
196
+ const store = new SessionStore(
197
+ pi as unknown as ExtensionAPI,
198
+ stateTracker as unknown as StateTracker,
199
+ )
200
+ store.register(vi.fn())
201
+
202
+ pi.emitSessionStart({
203
+ cwd: META.cwd,
204
+ sessionManager: { getSessionId: () => SESSION_ID },
205
+ })
206
+
207
+ expect(() => {
208
+ stateTracker.events.emit('idle')
209
+ }).not.toThrow()
210
+ })
211
+
212
+ it('ignores running event when session not started', () => {
213
+ const pi = makeFakePi()
214
+ const stateTracker = makeFakeStateTracker()
215
+
216
+ const store = new SessionStore(
217
+ pi as unknown as ExtensionAPI,
218
+ stateTracker as unknown as StateTracker,
219
+ )
220
+ store.register(vi.fn())
221
+
222
+ expect(() => {
223
+ stateTracker.events.emit('running')
224
+ }).not.toThrow()
225
+ })
226
+
227
+ it('ignores idle event when session not started', () => {
228
+ const pi = makeFakePi()
229
+ const stateTracker = makeFakeStateTracker()
230
+
231
+ const store = new SessionStore(
232
+ pi as unknown as ExtensionAPI,
233
+ stateTracker as unknown as StateTracker,
234
+ )
235
+ store.register(vi.fn())
236
+
237
+ expect(() => {
238
+ stateTracker.events.emit('idle')
239
+ }).not.toThrow()
240
+ })
241
+
242
+ it('writes running and idle states to state.json', async () => {
243
+ const pi = makeFakePi()
244
+ const stateTracker = makeFakeStateTracker()
245
+
246
+ const store = new SessionStore(
247
+ pi as unknown as ExtensionAPI,
248
+ stateTracker as unknown as StateTracker,
249
+ )
250
+ store.register(() => {})
251
+
252
+ pi.emitSessionStart({
253
+ cwd: META.cwd,
254
+ sessionManager: { getSessionId: () => SESSION_ID },
255
+ })
256
+ await new Promise((resolve) => setTimeout(resolve, 10))
257
+
258
+ stateTracker.events.emit('running')
259
+ await new Promise((resolve) => setTimeout(resolve, 10))
260
+
261
+ let record = readState().sessions[String(process.pid)]
262
+ expect(record?.state).toBe('running')
263
+
264
+ stateTracker.events.emit('idle')
265
+ await new Promise((resolve) => setTimeout(resolve, 10))
266
+
267
+ record = readState().sessions[String(process.pid)]
268
+ expect(record?.state).toBe('idle')
269
+ })
270
+
271
+ it('stops without errors after session started', () => {
272
+ const pi = makeFakePi()
273
+ const stateTracker = makeFakeStateTracker()
274
+
275
+ const store = new SessionStore(
276
+ pi as unknown as ExtensionAPI,
277
+ stateTracker as unknown as StateTracker,
278
+ )
279
+ store.register(vi.fn())
280
+
281
+ pi.emitSessionStart({
282
+ cwd: META.cwd,
283
+ sessionManager: { getSessionId: () => SESSION_ID },
284
+ })
285
+
286
+ expect(() => {
287
+ store.stop()
288
+ }).not.toThrow()
289
+ })
290
+ })
@@ -0,0 +1,83 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { basename } from 'node:path'
3
+
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+
6
+ import { Registrar } from '../shared/registrar.js'
7
+ import type { StateTracker } from '../state-tracker.js'
8
+ import type { SessionRecord } from './state-store.js'
9
+ import { updateState } from './state-store.js'
10
+
11
+ export interface SessionUpdate {
12
+ state: 'running' | 'idle'
13
+ }
14
+
15
+ export class SessionStore extends Registrar {
16
+ private readonly stateTracker: StateTracker
17
+ private sessionId: string | undefined = undefined
18
+ private meta: { pid: number; cwd: string; projectName: string } | undefined =
19
+ undefined
20
+
21
+ constructor(pi: ExtensionAPI, stateTracker: StateTracker) {
22
+ super(pi)
23
+ this.stateTracker = stateTracker
24
+ }
25
+
26
+ private async saveSession(updates: SessionUpdate): Promise<void> {
27
+ if (this.sessionId === undefined || this.meta === undefined) return
28
+
29
+ const now = Date.now()
30
+ const meta = this.meta
31
+ const sessionId = this.sessionId
32
+ const id = String(meta.pid)
33
+
34
+ await updateState((state) => {
35
+ const existing = state.sessions[id]
36
+ const stateChanged = updates.state !== existing?.state
37
+
38
+ const record: SessionRecord = {
39
+ pid: meta.pid,
40
+ sessionId,
41
+ cwd: meta.cwd,
42
+ projectName: meta.projectName,
43
+ startedAt: existing?.startedAt ?? now,
44
+ state: updates.state,
45
+ stateChangedAt: stateChanged ? now : existing.stateChangedAt,
46
+ }
47
+
48
+ return {
49
+ ...state,
50
+ sessions: { ...state.sessions, [id]: record },
51
+ }
52
+ })
53
+ }
54
+
55
+ protected override setup(): void {
56
+ this.pi.on('session_start', (_event, ctx) => {
57
+ let normalizedCwd = ctx.cwd
58
+ try {
59
+ normalizedCwd = realpathSync(ctx.cwd)
60
+ } catch {
61
+ // Use original path if realpathSync fails
62
+ }
63
+ const meta = {
64
+ pid: process.pid,
65
+ cwd: normalizedCwd,
66
+ projectName: basename(normalizedCwd),
67
+ }
68
+ const sessionId = ctx.sessionManager.getSessionId()
69
+ this.sessionId = sessionId
70
+ this.meta = meta
71
+ void this.saveSession({ state: 'idle' })
72
+ })
73
+
74
+ this.unsubscribes.push(
75
+ this.stateTracker.events.on('running', async () => {
76
+ await this.saveSession({ state: 'running' })
77
+ }),
78
+ this.stateTracker.events.on('idle', async () => {
79
+ await this.saveSession({ state: 'idle' })
80
+ }),
81
+ )
82
+ }
83
+ }