@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 +24 -38
- package/package.json +11 -6
- package/src/config.ts +2 -1
- package/src/dashboard/command.ts +9 -7
- package/src/dashboard/session-store.test.ts +290 -0
- package/src/dashboard/session-store.ts +83 -0
- package/src/dashboard/state-store.test.ts +231 -0
- package/src/dashboard/state-store.ts +64 -95
- package/src/dashboard/ui.ts +36 -41
- package/src/events.test.ts +115 -0
- package/src/events.ts +10 -25
- package/src/focus.ts +25 -25
- package/src/idle.ts +22 -37
- package/src/index.ts +19 -12
- 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 +145 -0
- package/src/state-tracker.ts +76 -0
- package/src/states.ts +3 -1
- package/src/tmux-title.ts +12 -10
- package/src/tool.ts +10 -7
- package/src/dashboard/state-tracker.ts +0 -174
- /package/src/{utils.ts → shared/utils.ts} +0 -0
package/src/events.ts
CHANGED
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
-
import
|
|
4
|
+
import { Registrar } from './shared/registrar.js'
|
|
5
|
+
import type { NotifyAction } from './shared/types.js'
|
|
5
6
|
|
|
6
7
|
export const PI_NOTIFY_EVENT = 'pi-notify:notify'
|
|
7
8
|
|
|
8
|
-
export class EventsNotifier {
|
|
9
|
-
private
|
|
10
|
-
private registered = false
|
|
9
|
+
export class EventsNotifier extends Registrar {
|
|
10
|
+
private readonly config: ResolvedNotifyConfig
|
|
11
11
|
|
|
12
|
-
constructor(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
register(notify: NotifyAction): void {
|
|
18
|
-
if (this.registered) return
|
|
19
|
-
this.registered = true
|
|
12
|
+
constructor(pi: ExtensionAPI, config: ResolvedNotifyConfig) {
|
|
13
|
+
super(pi)
|
|
14
|
+
this.config = config
|
|
15
|
+
}
|
|
20
16
|
|
|
17
|
+
protected override setup(notify: NotifyAction): void {
|
|
21
18
|
for (const [channel, message] of Object.entries(this.config.events)) {
|
|
22
|
-
if (
|
|
19
|
+
if (typeof message !== 'string' || message === '') continue
|
|
23
20
|
const unsubscribe = this.pi.events.on(channel, () => {
|
|
24
21
|
notify(message)
|
|
25
22
|
})
|
|
@@ -30,17 +27,5 @@ export class EventsNotifier {
|
|
|
30
27
|
notify(String(payload))
|
|
31
28
|
})
|
|
32
29
|
this.unsubscribes.push(customEventUnsub)
|
|
33
|
-
|
|
34
|
-
this.pi.on('session_shutdown', () => {
|
|
35
|
-
this.stop()
|
|
36
|
-
})
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
stop(): void {
|
|
40
|
-
this.unsubscribes.forEach((unsub) => {
|
|
41
|
-
unsub()
|
|
42
|
-
})
|
|
43
|
-
this.unsubscribes = []
|
|
44
|
-
this.registered = false
|
|
45
30
|
}
|
|
46
31
|
}
|
package/src/focus.ts
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import { Registrar } from './shared/registrar.js'
|
|
4
5
|
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
-
import type { Unsubscribe } from './types.js'
|
|
6
6
|
|
|
7
|
-
// xterm focus reporting (CSI ?1004): emitted by the terminal on focus gain/loss.
|
|
8
7
|
const FOCUS_IN = '\x1b[I'
|
|
9
8
|
const FOCUS_OUT = '\x1b[O'
|
|
10
9
|
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'
|
|
11
10
|
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'
|
|
12
11
|
|
|
13
|
-
export class FocusTracker {
|
|
12
|
+
export class FocusTracker extends Registrar {
|
|
13
|
+
private readonly titleTracker: TmuxTitleTracker
|
|
14
|
+
private readonly config: ResolvedNotifyConfig
|
|
14
15
|
private _focused: boolean | undefined = undefined
|
|
15
16
|
private _lastActivityAt = Date.now()
|
|
16
|
-
private unsubscribe: Unsubscribe | undefined
|
|
17
17
|
|
|
18
18
|
constructor(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
) {
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
titleTracker: TmuxTitleTracker,
|
|
21
|
+
config: ResolvedNotifyConfig,
|
|
22
|
+
) {
|
|
23
|
+
super(pi)
|
|
24
|
+
this.titleTracker = titleTracker
|
|
25
|
+
this.config = config
|
|
26
|
+
}
|
|
23
27
|
|
|
24
28
|
get isFocused(): boolean | undefined {
|
|
25
29
|
return this._focused
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
/** Timestamp of the last observed terminal input (fallback focus signal). */
|
|
29
32
|
get lastActivityAt(): number {
|
|
30
33
|
return this._lastActivityAt
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
protected override setup(): void {
|
|
34
37
|
this.pi.on('session_start', (_event, ctx) => {
|
|
35
38
|
const activate =
|
|
36
39
|
ctx.mode === 'tui' &&
|
|
@@ -38,24 +41,21 @@ export class FocusTracker {
|
|
|
38
41
|
if (!activate) return
|
|
39
42
|
this._lastActivityAt = Date.now()
|
|
40
43
|
process.stdout.write(ENABLE_FOCUS_REPORTING)
|
|
41
|
-
this.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
this.unsubscribes.push(
|
|
45
|
+
ctx.ui.onTerminalInput((data) => {
|
|
46
|
+
this._lastActivityAt = Date.now()
|
|
47
|
+
const result = this.consume(data)
|
|
48
|
+
if (result.gainedFocus) this.titleTracker.restore()
|
|
49
|
+
if (result.data !== data) return { consume: true }
|
|
50
|
+
return undefined
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
50
53
|
})
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
stop(): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.unsubscribe()
|
|
57
|
-
this.unsubscribe = undefined
|
|
58
|
-
}
|
|
56
|
+
override stop(): void {
|
|
57
|
+
process.stdout.write(DISABLE_FOCUS_REPORTING)
|
|
58
|
+
super.stop()
|
|
59
59
|
this._focused = undefined
|
|
60
60
|
}
|
|
61
61
|
|
package/src/idle.ts
CHANGED
|
@@ -1,52 +1,37 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
3
|
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
-
import
|
|
5
|
-
import type { NotifyAction } from './types.js'
|
|
4
|
+
import { Registrar } from './shared/registrar.js'
|
|
5
|
+
import type { NotifyAction } from './shared/types.js'
|
|
6
|
+
import type { StateTracker } from './state-tracker.js'
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
private
|
|
8
|
+
export class IdleNotifier extends Registrar {
|
|
9
|
+
private readonly config: ResolvedNotifyConfig
|
|
10
|
+
private readonly stateTracker: StateTracker
|
|
11
|
+
private hasActivity = false
|
|
11
12
|
|
|
12
13
|
constructor(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
clearTimeout(this.timer)
|
|
21
|
-
this.timer = null
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
startIdleTimer(notify: NotifyAction): void {
|
|
25
|
-
this.clearIdleTimer()
|
|
26
|
-
this.timer = setTimeout(() => {
|
|
27
|
-
this.clearIdleTimer()
|
|
28
|
-
notify('Idle')
|
|
29
|
-
}, IDLE_TIMEOUT_MS)
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
config: ResolvedNotifyConfig,
|
|
16
|
+
stateTracker: StateTracker,
|
|
17
|
+
) {
|
|
18
|
+
super(pi)
|
|
19
|
+
this.config = config
|
|
20
|
+
this.stateTracker = stateTracker
|
|
30
21
|
}
|
|
31
22
|
|
|
32
|
-
|
|
23
|
+
protected override setup(notify: NotifyAction): void {
|
|
33
24
|
if (!this.config.finished) return
|
|
34
25
|
|
|
35
26
|
this.pi.on('turn_start', () => {
|
|
36
|
-
this.
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
this.pi.on('message_start', () => {
|
|
40
|
-
this.clearIdleTimer()
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
this.pi.on('agent_settled', () => {
|
|
44
|
-
if (this.jobTracker.hasActiveJobs) return
|
|
45
|
-
this.startIdleTimer(notify)
|
|
27
|
+
this.hasActivity = true
|
|
46
28
|
})
|
|
47
29
|
|
|
48
|
-
this.
|
|
49
|
-
this.
|
|
50
|
-
|
|
30
|
+
this.unsubscribes.push(
|
|
31
|
+
this.stateTracker.events.on('idle', () => {
|
|
32
|
+
if (!this.hasActivity) return
|
|
33
|
+
notify('Idle')
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
51
36
|
}
|
|
52
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,15 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
|
4
4
|
|
|
5
5
|
import { loadConfig } from './config.js'
|
|
6
6
|
import { DashboardCommand } from './dashboard/command.js'
|
|
7
|
-
import {
|
|
7
|
+
import { SessionStore } from './dashboard/session-store.js'
|
|
8
8
|
import { EventsNotifier } from './events.js'
|
|
9
9
|
import { FocusTracker } from './focus.js'
|
|
10
10
|
import { IdleNotifier } from './idle.js'
|
|
11
11
|
import { JobTracker } from './jobs.js'
|
|
12
12
|
import { notify } from './notifier.js'
|
|
13
13
|
import { NotifyTest } from './notify-test.js'
|
|
14
|
+
import type { Registerable } from './shared/types.js'
|
|
15
|
+
import { StateTracker } from './state-tracker.js'
|
|
14
16
|
import { SessionState } from './states.js'
|
|
15
17
|
import { TmuxTitleTracker } from './tmux-title.js'
|
|
16
18
|
import { ToolCallNotifier } from './tool.js'
|
|
@@ -28,10 +30,11 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
28
30
|
const eventsNotifier = new EventsNotifier(pi, config)
|
|
29
31
|
const toolNotifier = new ToolCallNotifier(pi, config)
|
|
30
32
|
const jobTracker = new JobTracker(pi)
|
|
31
|
-
const
|
|
33
|
+
const stateTracker = new StateTracker(pi, jobTracker)
|
|
34
|
+
const idleNotifier = new IdleNotifier(pi, config, stateTracker)
|
|
32
35
|
const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
|
|
33
36
|
const sessionState = new SessionState(pi)
|
|
34
|
-
const
|
|
37
|
+
const sessionStore = new SessionStore(pi, stateTracker)
|
|
35
38
|
const dashboardCommand = new DashboardCommand(pi)
|
|
36
39
|
|
|
37
40
|
function notifyReal(body: string): void {
|
|
@@ -48,13 +51,17 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
|
48
51
|
notify(title, body)
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
const registrables: Registerable[] = [
|
|
55
|
+
tmuxTitleTracker,
|
|
56
|
+
focusTracker,
|
|
57
|
+
jobTracker,
|
|
58
|
+
stateTracker,
|
|
59
|
+
eventsNotifier,
|
|
60
|
+
toolNotifier,
|
|
61
|
+
idleNotifier,
|
|
62
|
+
sessionStore,
|
|
63
|
+
notifyTest,
|
|
64
|
+
dashboardCommand,
|
|
65
|
+
]
|
|
66
|
+
for (const r of registrables) r.register(notifyReal)
|
|
60
67
|
}
|
package/src/jobs.ts
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
import type { Unsubscribe } from './types.js'
|
|
1
|
+
import { Registrar } from './shared/registrar.js'
|
|
4
2
|
|
|
5
3
|
export const JOB_START_EVENT = 'pi-notify:job:start'
|
|
6
4
|
export const JOB_END_EVENT = 'pi-notify:job:end'
|
|
7
5
|
|
|
8
|
-
export class JobTracker {
|
|
6
|
+
export class JobTracker extends Registrar {
|
|
9
7
|
private activeJobs = new Set<string>()
|
|
10
|
-
private unsubscribes: Unsubscribe[] = []
|
|
11
|
-
private registered = false
|
|
12
8
|
private onEndListeners: Array<() => void> = []
|
|
13
9
|
|
|
14
|
-
constructor(private readonly pi: ExtensionAPI) {}
|
|
15
|
-
|
|
16
10
|
get hasActiveJobs(): boolean {
|
|
17
11
|
return this.activeJobs.size > 0
|
|
18
12
|
}
|
|
@@ -25,10 +19,7 @@ export class JobTracker {
|
|
|
25
19
|
}
|
|
26
20
|
}
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
if (this.registered) return
|
|
30
|
-
this.registered = true
|
|
31
|
-
|
|
22
|
+
protected override setup(): void {
|
|
32
23
|
const startUnsub = this.pi.events.on(JOB_START_EVENT, (params) => {
|
|
33
24
|
if (
|
|
34
25
|
typeof params === 'object' &&
|
|
@@ -53,18 +44,10 @@ export class JobTracker {
|
|
|
53
44
|
}
|
|
54
45
|
})
|
|
55
46
|
this.unsubscribes.push(endUnsub)
|
|
56
|
-
|
|
57
|
-
this.pi.on('session_shutdown', () => {
|
|
58
|
-
this.stop()
|
|
59
|
-
})
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
stop(): void {
|
|
63
|
-
|
|
64
|
-
unsub()
|
|
65
|
-
})
|
|
66
|
-
this.unsubscribes = []
|
|
49
|
+
override stop(): void {
|
|
50
|
+
super.stop()
|
|
67
51
|
this.activeJobs.clear()
|
|
68
|
-
this.registered = false
|
|
69
52
|
}
|
|
70
53
|
}
|
package/src/notify-test.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
2
|
|
|
3
|
-
import { notify } from './notifier.js'
|
|
3
|
+
import { notify as sendNotification } from './notifier.js'
|
|
4
|
+
import { sleep } from './shared/utils.js'
|
|
4
5
|
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
-
import { sleep } from './utils.js'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_BODY = 'This is a test notification.'
|
|
8
8
|
|
|
9
9
|
export class NotifyTest {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
) {
|
|
10
|
+
private readonly pi: ExtensionAPI
|
|
11
|
+
private readonly title: string
|
|
12
|
+
private readonly titleTracker: TmuxTitleTracker
|
|
13
|
+
|
|
14
|
+
constructor(pi: ExtensionAPI, title: string, titleTracker: TmuxTitleTracker) {
|
|
15
|
+
this.pi = pi
|
|
16
|
+
this.title = title
|
|
17
|
+
this.titleTracker = titleTracker
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
register(): void {
|
|
17
21
|
this.pi.registerCommand('notify-test', {
|
|
@@ -19,7 +23,7 @@ export class NotifyTest {
|
|
|
19
23
|
handler: async (args) => {
|
|
20
24
|
await sleep(3000)
|
|
21
25
|
this.titleTracker.mark()
|
|
22
|
-
|
|
26
|
+
sendNotification(this.title, args.trim() || DEFAULT_BODY)
|
|
23
27
|
},
|
|
24
28
|
})
|
|
25
29
|
}
|
|
@@ -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,145 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type { JobTracker } from './jobs.js'
|
|
5
|
+
import { StateTracker } from './state-tracker.js'
|
|
6
|
+
|
|
7
|
+
interface FakePi {
|
|
8
|
+
listeners: Map<string, Set<() => void>>
|
|
9
|
+
on(event: string, listener: () => void): void
|
|
10
|
+
emit(event: string): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeFakePi(): FakePi {
|
|
14
|
+
const listeners = new Map<string, Set<() => void>>()
|
|
15
|
+
return {
|
|
16
|
+
listeners,
|
|
17
|
+
on(event, listener) {
|
|
18
|
+
let set = listeners.get(event)
|
|
19
|
+
if (!set) {
|
|
20
|
+
set = new Set()
|
|
21
|
+
listeners.set(event, set)
|
|
22
|
+
}
|
|
23
|
+
set.add(listener)
|
|
24
|
+
},
|
|
25
|
+
emit(event) {
|
|
26
|
+
const set = listeners.get(event)
|
|
27
|
+
if (!set) return
|
|
28
|
+
for (const listener of [...set]) listener()
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeFakeJobTracker(): JobTracker {
|
|
34
|
+
return {
|
|
35
|
+
hasActiveJobs: false,
|
|
36
|
+
onEnd: () => () => {},
|
|
37
|
+
} as unknown as JobTracker
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function flush(): Promise<void> {
|
|
41
|
+
for (let i = 0; i < 5; i++) await Promise.resolve()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeTracker(pi: FakePi): {
|
|
45
|
+
tracker: StateTracker
|
|
46
|
+
states: string[]
|
|
47
|
+
} {
|
|
48
|
+
const states: string[] = []
|
|
49
|
+
const tracker = new StateTracker(
|
|
50
|
+
pi as unknown as ExtensionAPI,
|
|
51
|
+
makeFakeJobTracker(),
|
|
52
|
+
)
|
|
53
|
+
tracker.register(() => {})
|
|
54
|
+
tracker.events.on('running', () => {
|
|
55
|
+
states.push('running')
|
|
56
|
+
})
|
|
57
|
+
tracker.events.on('idle', () => {
|
|
58
|
+
states.push('idle')
|
|
59
|
+
})
|
|
60
|
+
return { tracker, states }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('StateTracker', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.useFakeTimers()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.useRealTimers()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('emits running on turn_start', async () => {
|
|
73
|
+
const pi = makeFakePi()
|
|
74
|
+
const { states } = makeTracker(pi)
|
|
75
|
+
|
|
76
|
+
pi.emit('turn_start')
|
|
77
|
+
|
|
78
|
+
await flush()
|
|
79
|
+
|
|
80
|
+
expect(states).toEqual(['running'])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('emits idle after the idle timeout following agent_settled', async () => {
|
|
84
|
+
const pi = makeFakePi()
|
|
85
|
+
const { states } = makeTracker(pi)
|
|
86
|
+
|
|
87
|
+
pi.emit('turn_start')
|
|
88
|
+
pi.emit('agent_settled')
|
|
89
|
+
vi.advanceTimersByTime(10000)
|
|
90
|
+
|
|
91
|
+
await flush()
|
|
92
|
+
|
|
93
|
+
expect(states).toEqual(['running', 'idle'])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('does not emit running repeatedly while already running', async () => {
|
|
97
|
+
const pi = makeFakePi()
|
|
98
|
+
const { states } = makeTracker(pi)
|
|
99
|
+
|
|
100
|
+
pi.emit('turn_start')
|
|
101
|
+
pi.emit('message_start')
|
|
102
|
+
pi.emit('tool_call')
|
|
103
|
+
pi.emit('turn_start')
|
|
104
|
+
|
|
105
|
+
await flush()
|
|
106
|
+
|
|
107
|
+
expect(states).toEqual(['running'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('emits running again after becoming idle', async () => {
|
|
111
|
+
const pi = makeFakePi()
|
|
112
|
+
const { states } = makeTracker(pi)
|
|
113
|
+
|
|
114
|
+
pi.emit('turn_start')
|
|
115
|
+
pi.emit('agent_settled')
|
|
116
|
+
vi.advanceTimersByTime(10000)
|
|
117
|
+
pi.emit('turn_start')
|
|
118
|
+
|
|
119
|
+
await flush()
|
|
120
|
+
|
|
121
|
+
expect(states).toEqual(['running', 'idle', 'running'])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('resets the idle timer on activity', async () => {
|
|
125
|
+
const pi = makeFakePi()
|
|
126
|
+
const { states } = makeTracker(pi)
|
|
127
|
+
|
|
128
|
+
pi.emit('turn_start')
|
|
129
|
+
pi.emit('agent_settled')
|
|
130
|
+
vi.advanceTimersByTime(9000)
|
|
131
|
+
pi.emit('tool_call')
|
|
132
|
+
vi.advanceTimersByTime(9000)
|
|
133
|
+
|
|
134
|
+
await flush()
|
|
135
|
+
|
|
136
|
+
expect(states).toEqual(['running'])
|
|
137
|
+
|
|
138
|
+
pi.emit('agent_settled')
|
|
139
|
+
vi.advanceTimersByTime(10000)
|
|
140
|
+
|
|
141
|
+
await flush()
|
|
142
|
+
|
|
143
|
+
expect(states).toEqual(['running', 'idle'])
|
|
144
|
+
})
|
|
145
|
+
})
|