@raidou/pi-notify 0.3.1 → 0.5.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.
@@ -0,0 +1,137 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { Registrar } from './registrar.js'
5
+ import type { NotifyAction, Unsubscribe } from './types.js'
6
+
7
+ type Listener = (...args: unknown[]) => void
8
+ type EventsListener = (payload: unknown) => void
9
+
10
+ interface FakePi {
11
+ listeners: Map<string, Set<Listener>>
12
+ eventListeners: Map<string, Set<EventsListener>>
13
+ on(event: string, listener: Listener): void
14
+ events: { on(event: string, listener: EventsListener): Unsubscribe }
15
+ emit(event: string, ...args: unknown[]): void
16
+ emitEvent(event: string, payload: unknown): void
17
+ }
18
+
19
+ function makeFakePi(): FakePi {
20
+ const listeners = new Map<string, Set<Listener>>()
21
+ const eventListeners = new Map<string, Set<EventsListener>>()
22
+ const pi: FakePi = {
23
+ listeners,
24
+ eventListeners,
25
+ on(event, listener) {
26
+ let set = listeners.get(event)
27
+ if (!set) {
28
+ set = new Set()
29
+ listeners.set(event, set)
30
+ }
31
+ set.add(listener)
32
+ },
33
+ events: {
34
+ on(event, listener) {
35
+ let set = eventListeners.get(event)
36
+ if (!set) {
37
+ set = new Set()
38
+ eventListeners.set(event, set)
39
+ }
40
+ set.add(listener)
41
+ return () => {
42
+ set.delete(listener)
43
+ }
44
+ },
45
+ },
46
+ emit(event, ...args) {
47
+ const set = listeners.get(event)
48
+ if (!set) return
49
+ for (const listener of [...set]) listener(...args)
50
+ },
51
+ emitEvent(event, payload) {
52
+ const set = eventListeners.get(event)
53
+ if (!set) return
54
+ for (const listener of [...set]) listener(payload)
55
+ },
56
+ }
57
+ return pi
58
+ }
59
+
60
+ class TrackingRegistrar extends Registrar {
61
+ setupCalls = 0
62
+ notifyUsed: NotifyAction | undefined
63
+ stopCalls = 0
64
+
65
+ protected override setup(notify: NotifyAction): void {
66
+ this.setupCalls += 1
67
+ this.notifyUsed = notify
68
+ const unsub: Unsubscribe = () => {}
69
+ this.unsubscribes.push(unsub)
70
+ this.unsubscribes.push(unsub)
71
+ }
72
+
73
+ override stop(): void {
74
+ super.stop()
75
+ this.stopCalls += 1
76
+ }
77
+
78
+ get isRegistered() {
79
+ return this.registered
80
+ }
81
+ get unsubscribesCount() {
82
+ return this.unsubscribes.length
83
+ }
84
+ }
85
+
86
+ describe('Registrar', () => {
87
+ it('runs setup once and is idempotent', () => {
88
+ const pi = makeFakePi()
89
+ const reg = new TrackingRegistrar(pi as unknown as ExtensionAPI)
90
+ const notify = vi.fn() as unknown as NotifyAction
91
+ reg.register(notify)
92
+ reg.register(notify)
93
+ expect(reg.setupCalls).toBe(1)
94
+ expect(reg.notifyUsed).toBe(notify)
95
+ })
96
+
97
+ it('passes notify to setup', () => {
98
+ const pi = makeFakePi()
99
+ const reg = new TrackingRegistrar(pi as unknown as ExtensionAPI)
100
+ const notify = vi.fn() as unknown as NotifyAction
101
+ reg.register(notify)
102
+ expect(reg.notifyUsed).toBe(notify)
103
+ })
104
+
105
+ it('triggers stop on session_shutdown', () => {
106
+ const pi = makeFakePi()
107
+ const reg = new TrackingRegistrar(pi as unknown as ExtensionAPI)
108
+ const notify = vi.fn() as unknown as NotifyAction
109
+ reg.register(notify)
110
+ expect(reg.stopCalls).toBe(0)
111
+ pi.emit('session_shutdown')
112
+ expect(reg.stopCalls).toBe(1)
113
+ expect(reg.isRegistered).toBe(false)
114
+ })
115
+
116
+ it('clears unsubscribes on stop', () => {
117
+ const pi = makeFakePi()
118
+ const reg = new TrackingRegistrar(pi as unknown as ExtensionAPI)
119
+ const notify = vi.fn() as unknown as NotifyAction
120
+ reg.register(notify)
121
+ expect(reg.unsubscribesCount).toBe(2)
122
+ reg.stop()
123
+ expect(reg.unsubscribesCount).toBe(0)
124
+ expect(reg.isRegistered).toBe(false)
125
+ })
126
+
127
+ it('re-registers after stop', () => {
128
+ const pi = makeFakePi()
129
+ const reg = new TrackingRegistrar(pi as unknown as ExtensionAPI)
130
+ const notify = vi.fn() as unknown as NotifyAction
131
+ reg.register(notify)
132
+ pi.emit('session_shutdown')
133
+ expect(reg.setupCalls).toBe(1)
134
+ reg.register(notify)
135
+ expect(reg.setupCalls).toBe(2)
136
+ })
137
+ })
@@ -0,0 +1,30 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+
3
+ import type { NotifyAction, Unsubscribe } from './types.js'
4
+
5
+ export abstract class Registrar {
6
+ protected readonly pi: ExtensionAPI
7
+ protected registered = false
8
+ protected unsubscribes: Unsubscribe[] = []
9
+
10
+ constructor(pi: ExtensionAPI) {
11
+ this.pi = pi
12
+ }
13
+
14
+ register(notify: NotifyAction): void {
15
+ if (this.registered) return
16
+ this.registered = true
17
+ this.setup(notify)
18
+ this.pi.on('session_shutdown', () => {
19
+ this.stop()
20
+ })
21
+ }
22
+
23
+ protected abstract setup(notify: NotifyAction): void
24
+
25
+ stop(): void {
26
+ for (const unsubscribe of this.unsubscribes) unsubscribe()
27
+ this.unsubscribes = []
28
+ this.registered = false
29
+ }
30
+ }
@@ -1,3 +1,7 @@
1
1
  export type Unsubscribe = () => void
2
2
 
3
3
  export type NotifyAction = (body: string) => void
4
+
5
+ export interface Registerable {
6
+ register(notify: NotifyAction): void
7
+ }
@@ -0,0 +1,360 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ import type { ResolvedNotifyConfig } from './config.js'
5
+ import type { JobTracker } from './jobs.js'
6
+ import { PI_NOTIFY_EVENT, StateTracker } from './state-tracker.js'
7
+
8
+ type EventsListener = (payload?: unknown) => void
9
+
10
+ interface FakePi {
11
+ listeners: Map<string, Set<(payload?: unknown) => void>>
12
+ eventListeners: Map<string, Set<EventsListener>>
13
+ on(event: string, listener: (payload?: unknown) => void): void
14
+ events: { on(event: string, listener: EventsListener): () => void }
15
+ emit(event: string, payload?: unknown): void
16
+ emitEvent(event: string, payload?: unknown): void
17
+ }
18
+
19
+ function makeFakePi(): FakePi {
20
+ const listeners = new Map<string, Set<(payload?: unknown) => void>>()
21
+ const eventListeners = new Map<string, Set<EventsListener>>()
22
+ return {
23
+ listeners,
24
+ eventListeners,
25
+ on(event, listener) {
26
+ let set = listeners.get(event)
27
+ if (!set) {
28
+ set = new Set()
29
+ listeners.set(event, set)
30
+ }
31
+ set.add(listener)
32
+ },
33
+ events: {
34
+ on(event, listener) {
35
+ let set = eventListeners.get(event)
36
+ if (!set) {
37
+ set = new Set()
38
+ eventListeners.set(event, set)
39
+ }
40
+ set.add(listener)
41
+ return () => {
42
+ set.delete(listener)
43
+ }
44
+ },
45
+ },
46
+ emit(event, payload) {
47
+ const set = listeners.get(event)
48
+ if (!set) return
49
+ for (const listener of [...set]) listener(payload)
50
+ },
51
+ emitEvent(event, payload) {
52
+ const set = eventListeners.get(event)
53
+ if (!set) return
54
+ for (const listener of [...set]) listener(payload)
55
+ },
56
+ }
57
+ }
58
+
59
+ function makeFakeJobTracker(): JobTracker {
60
+ return {
61
+ hasActiveJobs: false,
62
+ onEnd: () => () => {},
63
+ } as unknown as JobTracker
64
+ }
65
+
66
+ async function flush(): Promise<void> {
67
+ for (let i = 0; i < 5; i++) await Promise.resolve()
68
+ }
69
+
70
+ const BASE_CONFIG: ResolvedNotifyConfig = {
71
+ enabled: true,
72
+ notifyTools: new Set(['bash', 'read']),
73
+ events: {
74
+ 'permissions:ui_prompt': 'msg',
75
+ 'disabled:channel': false,
76
+ },
77
+ finished: true,
78
+ onlyNotifyWhenUnfocused: true,
79
+ unfocusedActivityThresholdMs: 0,
80
+ tmuxSymbol: '',
81
+ } as unknown as ResolvedNotifyConfig
82
+
83
+ function makeTracker(
84
+ pi: FakePi,
85
+ config: ResolvedNotifyConfig = BASE_CONFIG,
86
+ ): {
87
+ tracker: StateTracker
88
+ states: string[]
89
+ bodies: string[]
90
+ } {
91
+ const states: string[] = []
92
+ const bodies: string[] = []
93
+ const tracker = new StateTracker(
94
+ pi as unknown as ExtensionAPI,
95
+ makeFakeJobTracker(),
96
+ config,
97
+ )
98
+ tracker.register((body) => bodies.push(body))
99
+ tracker.events.on('running', () => {
100
+ states.push('running')
101
+ })
102
+ tracker.events.on('idle', () => {
103
+ states.push('idle')
104
+ })
105
+ return { tracker, states, bodies }
106
+ }
107
+
108
+ describe('StateTracker', () => {
109
+ beforeEach(() => {
110
+ vi.useFakeTimers()
111
+ })
112
+
113
+ afterEach(() => {
114
+ vi.useRealTimers()
115
+ })
116
+
117
+ it('emits running on turn_start', async () => {
118
+ const pi = makeFakePi()
119
+ const { states } = makeTracker(pi)
120
+
121
+ pi.emit('turn_start')
122
+
123
+ await flush()
124
+
125
+ expect(states).toEqual(['running'])
126
+ })
127
+
128
+ it('emits idle after the idle timeout following agent_settled', async () => {
129
+ const pi = makeFakePi()
130
+ const { states } = makeTracker(pi)
131
+
132
+ pi.emit('turn_start')
133
+ pi.emit('agent_settled')
134
+ vi.advanceTimersByTime(10000)
135
+
136
+ await flush()
137
+
138
+ expect(states).toEqual(['running', 'idle'])
139
+ })
140
+
141
+ it('does not emit running repeatedly while already running', async () => {
142
+ const pi = makeFakePi()
143
+ const { states } = makeTracker(pi)
144
+
145
+ pi.emit('turn_start')
146
+ pi.emit('message_start')
147
+ pi.emit('tool_call', {
148
+ type: 'tool_call',
149
+ toolCallId: 't1',
150
+ toolName: 'read',
151
+ })
152
+ pi.emit('turn_start')
153
+
154
+ await flush()
155
+
156
+ expect(states).toEqual(['running'])
157
+ })
158
+
159
+ it('emits tool only for tools in notifyTools', async () => {
160
+ const pi = makeFakePi()
161
+ const { tracker } = makeTracker(pi)
162
+ const tools: string[] = []
163
+ tracker.events.on('tool', (event) => {
164
+ tools.push(event.data)
165
+ })
166
+
167
+ pi.emit('tool_call', {
168
+ type: 'tool_call',
169
+ toolCallId: 't1',
170
+ toolName: 'bash',
171
+ })
172
+ pi.emit('tool_call', {
173
+ type: 'tool_call',
174
+ toolCallId: 't2',
175
+ toolName: 'grep',
176
+ })
177
+
178
+ await flush()
179
+
180
+ expect(tools).toEqual(['bash'])
181
+ })
182
+
183
+ it('emits event for configured channels and not for disabled ones', async () => {
184
+ const pi = makeFakePi()
185
+ const { tracker } = makeTracker(pi)
186
+ const events: string[] = []
187
+ tracker.events.on('event', (event) => {
188
+ events.push(event.data)
189
+ })
190
+
191
+ pi.emitEvent('permissions:ui_prompt', {})
192
+ pi.emitEvent('disabled:channel', {})
193
+
194
+ await flush()
195
+
196
+ expect(events).toEqual(['permissions:ui_prompt'])
197
+ })
198
+
199
+ it('unsubscribes from channel events on stop', async () => {
200
+ const pi = makeFakePi()
201
+ const { tracker } = makeTracker(pi)
202
+ const events: string[] = []
203
+ tracker.events.on('event', (event) => {
204
+ events.push(event.data)
205
+ })
206
+
207
+ tracker.stop()
208
+ pi.emitEvent('permissions:ui_prompt', {})
209
+
210
+ await flush()
211
+
212
+ expect(events).toEqual([])
213
+ })
214
+
215
+ it('emits running again after becoming idle', async () => {
216
+ const pi = makeFakePi()
217
+ const { states } = makeTracker(pi)
218
+
219
+ pi.emit('turn_start')
220
+ pi.emit('agent_settled')
221
+ vi.advanceTimersByTime(10000)
222
+ pi.emit('turn_start')
223
+
224
+ await flush()
225
+
226
+ expect(states).toEqual(['running', 'idle', 'running'])
227
+ })
228
+
229
+ it('notifies with the configured message when a custom event fires', () => {
230
+ const pi = makeFakePi()
231
+ const { bodies } = makeTracker(pi)
232
+
233
+ pi.emitEvent('permissions:ui_prompt', {})
234
+
235
+ expect(bodies).toEqual(['msg'])
236
+ })
237
+
238
+ it('does not notify for events disabled with false', () => {
239
+ const pi = makeFakePi()
240
+ const { bodies } = makeTracker(pi)
241
+
242
+ pi.emitEvent('disabled:channel', {})
243
+
244
+ expect(bodies).toEqual([])
245
+ })
246
+
247
+ it('does not notify for events disabled with an empty string', () => {
248
+ const pi = makeFakePi()
249
+ const { bodies } = makeTracker(pi, {
250
+ ...BASE_CONFIG,
251
+ events: { 'my:custom:event': '' },
252
+ })
253
+
254
+ pi.emitEvent('my:custom:event', {})
255
+
256
+ expect(bodies).toEqual([])
257
+ })
258
+
259
+ it('notifies from the custom channel', () => {
260
+ const pi = makeFakePi()
261
+ const { bodies } = makeTracker(pi)
262
+
263
+ pi.emitEvent(PI_NOTIFY_EVENT, 'custom payload')
264
+
265
+ expect(bodies).toEqual(['custom payload'])
266
+ })
267
+
268
+ it('notifies for tools in notifyTools', () => {
269
+ const pi = makeFakePi()
270
+ const { bodies } = makeTracker(pi)
271
+
272
+ pi.emit('tool_call', {
273
+ type: 'tool_call',
274
+ toolCallId: 't1',
275
+ toolName: 'read',
276
+ })
277
+
278
+ expect(bodies).toEqual(['Tool call: read'])
279
+ })
280
+
281
+ it('does not notify for tools not in notifyTools', () => {
282
+ const pi = makeFakePi()
283
+ const { bodies } = makeTracker(pi)
284
+
285
+ pi.emit('tool_call', {
286
+ type: 'tool_call',
287
+ toolCallId: 't1',
288
+ toolName: 'grep',
289
+ })
290
+
291
+ expect(bodies).toEqual([])
292
+ })
293
+
294
+ it('notifies Idle on idle when there was activity', async () => {
295
+ const pi = makeFakePi()
296
+ const { bodies } = makeTracker(pi)
297
+
298
+ pi.emit('turn_start')
299
+ pi.emit('agent_settled')
300
+ vi.advanceTimersByTime(10000)
301
+
302
+ await flush()
303
+
304
+ expect(bodies).toEqual(['Idle'])
305
+ })
306
+
307
+ it('does not notify Idle on idle without activity', async () => {
308
+ const pi = makeFakePi()
309
+ const { bodies } = makeTracker(pi)
310
+
311
+ pi.emit('agent_settled')
312
+ vi.advanceTimersByTime(10000)
313
+
314
+ await flush()
315
+
316
+ expect(bodies).toEqual([])
317
+ })
318
+
319
+ it('does not notify Idle when finished is disabled', async () => {
320
+ const pi = makeFakePi()
321
+ const { bodies } = makeTracker(pi, {
322
+ ...BASE_CONFIG,
323
+ finished: false,
324
+ })
325
+
326
+ pi.emit('turn_start')
327
+ pi.emit('agent_settled')
328
+ vi.advanceTimersByTime(10000)
329
+
330
+ await flush()
331
+
332
+ expect(bodies).toEqual([])
333
+ })
334
+
335
+ it('resets the idle timer on activity', async () => {
336
+ const pi = makeFakePi()
337
+ const { states } = makeTracker(pi)
338
+
339
+ pi.emit('turn_start')
340
+ pi.emit('agent_settled')
341
+ vi.advanceTimersByTime(9000)
342
+ pi.emit('tool_call', {
343
+ type: 'tool_call',
344
+ toolCallId: 't2',
345
+ toolName: 'bash',
346
+ })
347
+ vi.advanceTimersByTime(9000)
348
+
349
+ await flush()
350
+
351
+ expect(states).toEqual(['running'])
352
+
353
+ pi.emit('agent_settled')
354
+ vi.advanceTimersByTime(10000)
355
+
356
+ await flush()
357
+
358
+ expect(states).toEqual(['running', 'idle'])
359
+ })
360
+ })
@@ -0,0 +1,123 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import Emittery from 'emittery'
3
+
4
+ import type { ResolvedNotifyConfig } from './config.js'
5
+ import type { JobTracker } from './jobs.js'
6
+ import { Registrar } from './shared/registrar.js'
7
+ import type { NotifyAction } from './shared/types.js'
8
+
9
+ export const PI_NOTIFY_EVENT = 'pi-notify:notify'
10
+
11
+ const IDLE_TIMEOUT_MS = 10000
12
+
13
+ export class StateTracker extends Registrar {
14
+ readonly events = new Emittery<{
15
+ running: never
16
+ idle: never
17
+ tool: string
18
+ event: string
19
+ }>()
20
+
21
+ private readonly jobTracker: JobTracker
22
+ private readonly config: ResolvedNotifyConfig
23
+ private idleTimer: NodeJS.Timeout | null = null
24
+ private running = false
25
+ private hasActivity = false
26
+ private notify: NotifyAction = () => {}
27
+
28
+ constructor(
29
+ pi: ExtensionAPI,
30
+ jobTracker: JobTracker,
31
+ config: ResolvedNotifyConfig,
32
+ ) {
33
+ super(pi)
34
+ this.jobTracker = jobTracker
35
+ this.config = config
36
+ }
37
+
38
+ private startIdleTimer(): void {
39
+ if (this.jobTracker.hasActiveJobs) return
40
+ this.clearIdleTimer()
41
+ this.idleTimer = setTimeout(() => {
42
+ this.idleTimer = null
43
+ this.running = false
44
+ void this.events.emit('idle')
45
+ if (this.hasActivity && this.config.finished) {
46
+ this.notify('Idle')
47
+ }
48
+ }, IDLE_TIMEOUT_MS)
49
+ }
50
+
51
+ private clearIdleTimer(): void {
52
+ if (this.idleTimer) {
53
+ clearTimeout(this.idleTimer)
54
+ this.idleTimer = null
55
+ }
56
+ }
57
+
58
+ private markRunning(): void {
59
+ if (this.running) return
60
+ this.running = true
61
+ void this.events.emit('running')
62
+ }
63
+
64
+ private setupPiEvents() {
65
+ for (const [channel, message] of Object.entries(this.config.events)) {
66
+ if (typeof message !== 'string' || message === '') continue
67
+ const unsubscribe = this.pi.events.on(channel, () => {
68
+ this.notify(message)
69
+ void this.events.emit('event', channel)
70
+ })
71
+ this.unsubscribes.push(unsubscribe)
72
+ }
73
+
74
+ const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
75
+ this.notify(String(payload))
76
+ void this.events.emit('event', PI_NOTIFY_EVENT)
77
+ })
78
+ this.unsubscribes.push(customEventUnsub)
79
+ }
80
+
81
+ private setupToolCall() {
82
+ this.pi.on('tool_call', (event) => {
83
+ if (this.config.notifyTools.has(event.toolName)) {
84
+ this.notify(`Tool call: ${event.toolName}`)
85
+ void this.events.emit('tool', event.toolName)
86
+ }
87
+ this.clearIdleTimer()
88
+ })
89
+ }
90
+
91
+ protected override setup(notify: NotifyAction): void {
92
+ this.notify = notify
93
+
94
+ this.setupPiEvents()
95
+ this.setupToolCall()
96
+
97
+ this.pi.on('turn_start', () => {
98
+ this.hasActivity = true
99
+ this.markRunning()
100
+ this.clearIdleTimer()
101
+ })
102
+
103
+ this.pi.on('message_start', () => {
104
+ this.clearIdleTimer()
105
+ })
106
+
107
+ this.pi.on('agent_settled', () => {
108
+ this.startIdleTimer()
109
+ })
110
+
111
+ this.unsubscribes.push(
112
+ this.jobTracker.onEnd(() => {
113
+ this.startIdleTimer()
114
+ }),
115
+ )
116
+ }
117
+
118
+ override stop(): void {
119
+ super.stop()
120
+ this.clearIdleTimer()
121
+ this.hasActivity = false
122
+ }
123
+ }
package/src/states.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
2
 
3
3
  export class SessionState {
4
+ private readonly pi: ExtensionAPI
4
5
  public hasUI = false
5
6
 
6
- constructor(private readonly pi: ExtensionAPI) {
7
+ constructor(pi: ExtensionAPI) {
8
+ this.pi = pi
7
9
  this.register()
8
10
  }
9
11