@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 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,43 +66,21 @@ function endBackgroundJob(jobId: string): void {
44
66
 
45
67
  Events are automatically cleaned up on `session_shutdown`.
46
68
 
47
- ## Configuration
69
+ ## Commands
48
70
 
49
- All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everything is optional.
71
+ ### `/notify-dashboard`
50
72
 
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
- ```
73
+ Open a full-screen TUI dashboard listing all pi sessions, with summary counts (total, running, idle) and an auto-refresh every second.
68
74
 
69
- ### Disabling specific events
75
+ Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded), `PROJECT`, `UPTIME`.
70
76
 
71
- To disable a specific event, set its message to an empty string:
77
+ Keybindings:
72
78
 
73
- ```jsonc
74
- {
75
- "piNotify": {
76
- "events": {
77
- "permissions:ui_prompt": "", // disable permission notifications
78
- },
79
- },
80
- }
81
- ```
79
+ - `↑` / `↓` or `j` / `k` — scroll
80
+ - `r` — refresh
81
+ - `q` or `esc` — close
82
82
 
83
- ## Testing
83
+ ### `/notify-test`
84
84
 
85
85
  Run `/notify-test` inside pi to fire a test notification.
86
86
  Pass a string argument (e.g., `/notify-test hello`) to override the body.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.3.1",
3
+ "version": "0.5.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,22 +28,29 @@
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
+ "lodash-es": "^4.18.1",
40
+ "node-notifier": "^10.0.1",
41
+ "proper-lockfile": "^4.1.2"
38
42
  },
39
43
  "peerDependencies": {
40
44
  "@earendil-works/pi-coding-agent": ">=0.79.0"
41
45
  },
42
46
  "devDependencies": {
43
47
  "@raidou/eslint-config-base": "^4.4.3",
48
+ "@types/lodash-es": "^4.17.12",
44
49
  "@types/node": "^22.0.0",
50
+ "@types/proper-lockfile": "^4.1.4",
45
51
  "eslint": "^10.7.0",
46
52
  "prettier": "^3.9.5",
47
- "typescript": "^5.6.0"
53
+ "typescript": "^5.6.0",
54
+ "vitest": "^4.1.11"
48
55
  }
49
56
  }
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 {
@@ -12,7 +13,6 @@ interface NotifyConfig {
12
13
  readonly notifyTools?: readonly string[]
13
14
  readonly events?: NotifyEventsConfig
14
15
  readonly finished?: boolean
15
- readonly finishedThrottleSecs?: number
16
16
  readonly onlyNotifyWhenUnfocused?: boolean
17
17
  readonly unfocusedActivityThresholdSecs?: number
18
18
  readonly tmuxSymbol?: string
@@ -23,7 +23,6 @@ export interface ResolvedNotifyConfig {
23
23
  readonly notifyTools: ReadonlySet<string>
24
24
  readonly events: NotifyEventsConfig
25
25
  readonly finished: boolean
26
- readonly finishedThrottleMs: number
27
26
  readonly onlyNotifyWhenUnfocused: boolean
28
27
  readonly unfocusedActivityThresholdMs: number
29
28
  readonly tmuxSymbol: string
@@ -66,7 +65,6 @@ export function loadConfig(): ResolvedNotifyConfig {
66
65
  notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
67
66
  events,
68
67
  finished: cfg.finished ?? true,
69
- finishedThrottleMs: Math.max(0, (cfg.finishedThrottleSecs ?? 0) * 1000),
70
68
  onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
71
69
  unfocusedActivityThresholdMs: Math.max(
72
70
  0,
@@ -1,33 +1,30 @@
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
- import { createDashboard } from './ui.js'
5
-
6
- export class DashboardCommand {
7
- constructor(private readonly pi: ExtensionAPI) {}
3
+ import { Dashboard } from './ui/dashboard.js'
8
4
 
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) => {
16
- const dashboard = createDashboard({
13
+ let closed = false
14
+ const dashboard = new Dashboard({
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
  })
25
26
 
26
- return {
27
- render: dashboard.render.bind(dashboard),
28
- handleInput: dashboard.handleInput.bind(dashboard),
29
- invalidate: dashboard.invalidate.bind(dashboard),
30
- }
27
+ return dashboard
31
28
  })
32
29
 
33
30
  return undefined
@@ -0,0 +1,6 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
4
+
5
+ export const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
6
+ export const STATE_TMP_FILE = `${STATE_FILE}.tmp`
@@ -0,0 +1,438 @@
1
+ import { rmSync, unlinkSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+ import {
6
+ afterAll,
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ it,
12
+ vi,
13
+ } from 'vitest'
14
+
15
+ vi.mock('./consts.js', async () => {
16
+ const { mkdtempSync } = await import('node:fs')
17
+ const path = await import('node:path')
18
+ const { tmpdir } = await import('node:os')
19
+
20
+ const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-test-'))
21
+ return {
22
+ STATE_FILE: path.join(stateDir, 'state.json'),
23
+ STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
24
+ }
25
+ })
26
+
27
+ import type { ResolvedNotifyConfig } from '../config.js'
28
+ import type { JobTracker } from '../jobs.js'
29
+ import { StateTracker } from '../state-tracker.js'
30
+ import { STATE_FILE } from './consts.js'
31
+ import { SessionStore } from './session-store.js'
32
+ import { readState, updateState } from './state-store.js'
33
+
34
+ type EventsListener = (payload: unknown) => void
35
+
36
+ interface FakeStateTracker {
37
+ events: {
38
+ on(event: string, listener: EventsListener): () => void
39
+ emit(event: string, data?: unknown): void
40
+ }
41
+ }
42
+
43
+ interface FakeSessionManager {
44
+ getSessionId(): string
45
+ }
46
+
47
+ interface FakePiContext {
48
+ cwd: string
49
+ sessionManager: FakeSessionManager
50
+ }
51
+
52
+ interface FakePi {
53
+ listeners: Map<string, Set<(...args: unknown[]) => void>>
54
+ eventListeners: Map<string, Set<EventsListener>>
55
+ on(event: string, listener: (...args: unknown[]) => void): void
56
+ events: { on(event: string, listener: EventsListener): () => void }
57
+ emit(event: string, ...args: unknown[]): void
58
+ emitEvent(event: string, payload: unknown): void
59
+ emitSessionStart(ctx: FakePiContext): void
60
+ }
61
+
62
+ function makeFakePi(): FakePi {
63
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
64
+ const eventListeners = new Map<string, Set<EventsListener>>()
65
+ const pi: FakePi = {
66
+ listeners,
67
+ eventListeners,
68
+ on(event, listener) {
69
+ let set = listeners.get(event)
70
+ if (!set) {
71
+ set = new Set()
72
+ listeners.set(event, set)
73
+ }
74
+ set.add(listener)
75
+ },
76
+ events: {
77
+ on(event, listener) {
78
+ let set = eventListeners.get(event)
79
+ if (!set) {
80
+ set = new Set()
81
+ eventListeners.set(event, set)
82
+ }
83
+ set.add(listener)
84
+ return () => {
85
+ set.delete(listener)
86
+ }
87
+ },
88
+ },
89
+ emit(event, ...args) {
90
+ const set = listeners.get(event)
91
+ if (!set) return
92
+ for (const listener of [...set]) listener(...args)
93
+ },
94
+ emitEvent(event, payload) {
95
+ const set = eventListeners.get(event)
96
+ if (!set) return
97
+ for (const listener of [...set]) listener(payload)
98
+ },
99
+ emitSessionStart(ctx) {
100
+ const set = listeners.get('session_start')
101
+ if (!set) return
102
+ for (const listener of [...set]) listener(undefined, ctx)
103
+ },
104
+ }
105
+ return pi
106
+ }
107
+
108
+ function makeFakeStateTracker(): FakeStateTracker {
109
+ const listeners = new Map<string, Set<EventsListener>>()
110
+ return {
111
+ events: {
112
+ on(event, listener) {
113
+ let set = listeners.get(event)
114
+ if (!set) {
115
+ set = new Set()
116
+ listeners.set(event, set)
117
+ }
118
+ set.add(listener)
119
+ return () => {
120
+ set.delete(listener)
121
+ }
122
+ },
123
+ emit(event, data?: unknown) {
124
+ const set = listeners.get(event)
125
+ if (!set) return
126
+ for (const listener of [...set]) listener({ data })
127
+ },
128
+ },
129
+ }
130
+ }
131
+
132
+ const SESSION_ID = 'test-session-123'
133
+ const META = {
134
+ cwd: '/test/project',
135
+ projectName: 'test-project',
136
+ }
137
+
138
+ afterAll(() => {
139
+ rmSync(dirname(STATE_FILE), { recursive: true, force: true })
140
+ })
141
+
142
+ describe('SessionStore', () => {
143
+ const testLockFile = `${STATE_FILE}.lock`
144
+
145
+ beforeEach(async () => {
146
+ try {
147
+ unlinkSync(testLockFile)
148
+ } catch {
149
+ // ignore
150
+ }
151
+ await updateState(() => ({ version: 2, sessions: {} }))
152
+ })
153
+
154
+ afterEach(() => {
155
+ try {
156
+ unlinkSync(testLockFile)
157
+ } catch {
158
+ // ignore
159
+ }
160
+ })
161
+
162
+ it('creates instance and registers event listeners', () => {
163
+ const pi = makeFakePi()
164
+ const stateTracker = makeFakeStateTracker()
165
+ const emitSpy = vi.spyOn(stateTracker.events, 'on')
166
+ const piOnSpy = vi.spyOn(pi, 'on')
167
+
168
+ const store = new SessionStore(
169
+ pi as unknown as ExtensionAPI,
170
+ stateTracker as unknown as StateTracker,
171
+ )
172
+ store.register(vi.fn())
173
+
174
+ expect(emitSpy).toHaveBeenCalledTimes(4)
175
+ expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
176
+ expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
177
+ expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
178
+ expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
179
+ expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
180
+ })
181
+
182
+ it('handles session_start event without errors', () => {
183
+ const pi = makeFakePi()
184
+ const stateTracker = makeFakeStateTracker()
185
+
186
+ const store = new SessionStore(
187
+ pi as unknown as ExtensionAPI,
188
+ stateTracker as unknown as StateTracker,
189
+ )
190
+ store.register(vi.fn())
191
+
192
+ expect(() => {
193
+ pi.emitSessionStart({
194
+ cwd: META.cwd,
195
+ sessionManager: { getSessionId: () => SESSION_ID },
196
+ })
197
+ }).not.toThrow()
198
+ })
199
+
200
+ it('handles running event without errors', () => {
201
+ const pi = makeFakePi()
202
+ const stateTracker = makeFakeStateTracker()
203
+
204
+ const store = new SessionStore(
205
+ pi as unknown as ExtensionAPI,
206
+ stateTracker as unknown as StateTracker,
207
+ )
208
+ store.register(vi.fn())
209
+
210
+ pi.emitSessionStart({
211
+ cwd: META.cwd,
212
+ sessionManager: { getSessionId: () => SESSION_ID },
213
+ })
214
+
215
+ expect(() => {
216
+ stateTracker.events.emit('running')
217
+ }).not.toThrow()
218
+ })
219
+
220
+ it('handles idle event without errors', () => {
221
+ const pi = makeFakePi()
222
+ const stateTracker = makeFakeStateTracker()
223
+
224
+ const store = new SessionStore(
225
+ pi as unknown as ExtensionAPI,
226
+ stateTracker as unknown as StateTracker,
227
+ )
228
+ store.register(vi.fn())
229
+
230
+ pi.emitSessionStart({
231
+ cwd: META.cwd,
232
+ sessionManager: { getSessionId: () => SESSION_ID },
233
+ })
234
+
235
+ expect(() => {
236
+ stateTracker.events.emit('idle')
237
+ }).not.toThrow()
238
+ })
239
+
240
+ it('ignores running event when session not started', () => {
241
+ const pi = makeFakePi()
242
+ const stateTracker = makeFakeStateTracker()
243
+
244
+ const store = new SessionStore(
245
+ pi as unknown as ExtensionAPI,
246
+ stateTracker as unknown as StateTracker,
247
+ )
248
+ store.register(vi.fn())
249
+
250
+ expect(() => {
251
+ stateTracker.events.emit('running')
252
+ }).not.toThrow()
253
+ })
254
+
255
+ it('ignores idle event when session not started', () => {
256
+ const pi = makeFakePi()
257
+ const stateTracker = makeFakeStateTracker()
258
+
259
+ const store = new SessionStore(
260
+ pi as unknown as ExtensionAPI,
261
+ stateTracker as unknown as StateTracker,
262
+ )
263
+ store.register(vi.fn())
264
+
265
+ expect(() => {
266
+ stateTracker.events.emit('idle')
267
+ }).not.toThrow()
268
+ })
269
+
270
+ it('writes running and idle states to state.json', async () => {
271
+ const pi = makeFakePi()
272
+ const stateTracker = makeFakeStateTracker()
273
+
274
+ const store = new SessionStore(
275
+ pi as unknown as ExtensionAPI,
276
+ stateTracker as unknown as StateTracker,
277
+ )
278
+ store.register(() => {})
279
+
280
+ pi.emitSessionStart({
281
+ cwd: META.cwd,
282
+ sessionManager: { getSessionId: () => SESSION_ID },
283
+ })
284
+ await new Promise((resolve) => setTimeout(resolve, 10))
285
+
286
+ stateTracker.events.emit('running')
287
+ await new Promise((resolve) => setTimeout(resolve, 10))
288
+
289
+ let record = readState().sessions[String(process.pid)]
290
+ expect(record?.state).toBe('running')
291
+
292
+ stateTracker.events.emit('idle')
293
+ await new Promise((resolve) => setTimeout(resolve, 10))
294
+
295
+ record = readState().sessions[String(process.pid)]
296
+ expect(record?.state).toBe('idle')
297
+ })
298
+
299
+ it('stops without errors after session started', () => {
300
+ const pi = makeFakePi()
301
+ const stateTracker = makeFakeStateTracker()
302
+
303
+ const store = new SessionStore(
304
+ pi as unknown as ExtensionAPI,
305
+ stateTracker as unknown as StateTracker,
306
+ )
307
+ store.register(vi.fn())
308
+
309
+ pi.emitSessionStart({
310
+ cwd: META.cwd,
311
+ sessionManager: { getSessionId: () => SESSION_ID },
312
+ })
313
+
314
+ expect(() => {
315
+ store.stop()
316
+ }).not.toThrow()
317
+ })
318
+
319
+ it('clears runningSince on idle transition without accumulating duration', async () => {
320
+ await updateState(() => ({ version: 2, sessions: {} }))
321
+ const now = Date.now()
322
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
323
+ const pi = makeFakePi()
324
+ const stateTracker = makeFakeStateTracker()
325
+ const store = new SessionStore(
326
+ pi as unknown as ExtensionAPI,
327
+ stateTracker as unknown as StateTracker,
328
+ )
329
+ store.register(() => {})
330
+
331
+ pi.emitSessionStart({
332
+ cwd: META.cwd,
333
+ sessionManager: { getSessionId: () => SESSION_ID },
334
+ })
335
+ await new Promise((resolve) => setTimeout(resolve, 20))
336
+
337
+ stateTracker.events.emit('running')
338
+ await new Promise((resolve) => setTimeout(resolve, 20))
339
+
340
+ nowSpy.mockReturnValue(now + 5000)
341
+ stateTracker.events.emit('idle')
342
+ await new Promise((resolve) => setTimeout(resolve, 20))
343
+
344
+ const record = readState().sessions[String(process.pid)]
345
+ expect(record).not.toHaveProperty('runningTime')
346
+ expect(record?.startedRunningAt).toBeUndefined()
347
+ nowSpy.mockRestore()
348
+ })
349
+
350
+ it('does not update state on tool emission outside notifyTools', async () => {
351
+ await updateState(() => ({ version: 2, sessions: {} }))
352
+ const pi = makeFakePi()
353
+ const jobTracker = {
354
+ hasActiveJobs: false,
355
+ onEnd: () => () => {},
356
+ } as unknown as JobTracker
357
+ const tracker = new StateTracker(
358
+ pi as unknown as ExtensionAPI,
359
+ jobTracker,
360
+ {
361
+ notifyTools: new Set(['read']),
362
+ events: {},
363
+ } as unknown as ResolvedNotifyConfig,
364
+ )
365
+ tracker.register(() => {})
366
+
367
+ const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
368
+ store.register(() => {})
369
+
370
+ pi.emitSessionStart({
371
+ cwd: META.cwd,
372
+ sessionManager: { getSessionId: () => SESSION_ID },
373
+ })
374
+ await new Promise((resolve) => setTimeout(resolve, 20))
375
+
376
+ expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
377
+
378
+ pi.emit('tool_call', {
379
+ type: 'tool_call',
380
+ toolCallId: 't1',
381
+ toolName: 'grep',
382
+ })
383
+ await new Promise((resolve) => setTimeout(resolve, 20))
384
+
385
+ const record = readState().sessions[String(process.pid)]
386
+ expect(record?.state).toBe('idle')
387
+ })
388
+
389
+ it('updates state immediately on event emission', async () => {
390
+ await updateState(() => ({ version: 2, sessions: {} }))
391
+ const pi = makeFakePi()
392
+ const jobTracker = {
393
+ hasActiveJobs: false,
394
+ onEnd: () => () => {},
395
+ } as unknown as JobTracker
396
+ const tracker = new StateTracker(
397
+ pi as unknown as ExtensionAPI,
398
+ jobTracker,
399
+ {
400
+ notifyTools: new Set(['bash']),
401
+ events: { 'permissions:ui_prompt': 'msg' },
402
+ } as unknown as ResolvedNotifyConfig,
403
+ )
404
+ tracker.register(() => {})
405
+
406
+ const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
407
+ store.register(() => {})
408
+
409
+ pi.emitSessionStart({
410
+ cwd: META.cwd,
411
+ sessionManager: { getSessionId: () => SESSION_ID },
412
+ })
413
+ await new Promise((resolve) => setTimeout(resolve, 20))
414
+
415
+ pi.emitEvent('permissions:ui_prompt', {})
416
+ await new Promise((resolve) => setTimeout(resolve, 20))
417
+
418
+ const record = readState().sessions[String(process.pid)]
419
+ expect(record?.state).toBe('event:permissions:ui_prompt')
420
+ })
421
+
422
+ it('ignores tool and event emissions before session_start', async () => {
423
+ await updateState(() => ({ version: 2, sessions: {} }))
424
+ const pi = makeFakePi()
425
+ const stateTracker = makeFakeStateTracker()
426
+ const store = new SessionStore(
427
+ pi as unknown as ExtensionAPI,
428
+ stateTracker as unknown as StateTracker,
429
+ )
430
+ store.register(() => {})
431
+
432
+ stateTracker.events.emit('tool', 'grep')
433
+ stateTracker.events.emit('event', 'permissions:ui_prompt')
434
+ await new Promise((resolve) => setTimeout(resolve, 20))
435
+
436
+ expect(readState().sessions).toEqual({})
437
+ })
438
+ })