@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/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 type { NotifyAction, Unsubscribe } from './types.js'
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 unsubscribes: Unsubscribe[] = []
10
- private registered = false
9
+ export class EventsNotifier extends Registrar {
10
+ private readonly config: ResolvedNotifyConfig
11
11
 
12
- constructor(
13
- private readonly pi: ExtensionAPI,
14
- private readonly config: ResolvedNotifyConfig,
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 (!message) continue
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
- private readonly pi: ExtensionAPI,
20
- private readonly titleTracker: TmuxTitleTracker,
21
- private readonly config: ResolvedNotifyConfig,
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
- register(): void {
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.unsubscribe = ctx.ui.onTerminalInput((data) => {
42
- this._lastActivityAt = Date.now()
43
- const result = this.consume(data)
44
- if (result.gainedFocus) this.titleTracker.restore()
45
- return result.data === data ? undefined : { data: result.data }
46
- })
47
- })
48
- this.pi.on('session_shutdown', () => {
49
- this.stop()
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
- if (this.unsubscribe) {
55
- process.stdout.write(DISABLE_FOCUS_REPORTING)
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 type { JobTracker } from './jobs.js'
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
- const IDLE_TIMEOUT_MS = 10000
8
-
9
- export class IdleNotifier {
10
- private timer: NodeJS.Timeout | null = null
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
- private readonly pi: ExtensionAPI,
14
- private readonly config: ResolvedNotifyConfig,
15
- private readonly jobTracker: JobTracker,
16
- ) {}
17
-
18
- clearIdleTimer(): void {
19
- if (this.timer) {
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
- register(notify: NotifyAction): void {
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.clearIdleTimer()
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.jobTracker.onEnd(() => {
49
- this.startIdleTimer(notify)
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 { StateTracker } from './dashboard/state-tracker.js'
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 idleNotifier = new IdleNotifier(pi, config, jobTracker)
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 stateTracker = new StateTracker(pi)
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
- tmuxTitleTracker.register()
52
- focusTracker.register()
53
- jobTracker.register()
54
- eventsNotifier.register(notifyReal)
55
- toolNotifier.register(notifyReal)
56
- idleNotifier.register(notifyReal)
57
- notifyTest.register()
58
- stateTracker.register()
59
- dashboardCommand.register()
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 type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
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
- register(): void {
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
- this.unsubscribes.forEach((unsub) => {
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
  }
@@ -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
- constructor(
11
- private readonly pi: ExtensionAPI,
12
- private readonly title: string,
13
- private readonly titleTracker: TmuxTitleTracker,
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
- notify(this.title, args.trim() || DEFAULT_BODY)
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
+ }
@@ -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,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
+ })