@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.
- package/README.md +33 -33
- package/package.json +13 -6
- package/src/config.ts +2 -4
- package/src/dashboard/command.ts +11 -14
- package/src/dashboard/consts.ts +6 -0
- package/src/dashboard/session-store.test.ts +438 -0
- package/src/dashboard/session-store.ts +87 -0
- package/src/dashboard/state-store.test.ts +303 -0
- package/src/dashboard/state-store.ts +124 -99
- package/src/dashboard/ui/columns.ts +102 -0
- package/src/dashboard/ui/dashboard.test.ts +118 -0
- package/src/dashboard/ui/dashboard.ts +122 -0
- package/src/focus.ts +25 -25
- package/src/index.ts +16 -18
- package/src/jobs.ts +5 -22
- package/src/notify-test.ts +12 -8
- package/src/shared/registrar.test.ts +137 -0
- package/src/shared/registrar.ts +30 -0
- package/src/{types.ts → shared/types.ts} +4 -0
- package/src/state-tracker.test.ts +360 -0
- package/src/state-tracker.ts +123 -0
- package/src/states.ts +3 -1
- package/src/tmux-title.ts +12 -10
- package/src/dashboard/state-tracker.ts +0 -174
- package/src/dashboard/ui.ts +0 -198
- package/src/events.ts +0 -46
- package/src/idle.ts +0 -52
- package/src/tool.ts +0 -18
- /package/src/{utils.ts → shared/utils.ts} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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(
|
|
7
|
+
constructor(pi: ExtensionAPI) {
|
|
8
|
+
this.pi = pi
|
|
7
9
|
this.register()
|
|
8
10
|
}
|
|
9
11
|
|