@raidou/pi-notify 0.4.0 → 0.5.1

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
@@ -66,7 +66,21 @@ function endBackgroundJob(jobId: string): void {
66
66
 
67
67
  Events are automatically cleaned up on `session_shutdown`.
68
68
 
69
- ## Testing
69
+ ## Commands
70
+
71
+ ### `/notify-dashboard`
72
+
73
+ Open a full-screen TUI dashboard listing all pi sessions, with summary counts (total, running, idle) and an auto-refresh every second.
74
+
75
+ Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded), `PROJECT`, `UPTIME`.
76
+
77
+ Keybindings:
78
+
79
+ - `↑` / `↓` or `j` / `k` — scroll
80
+ - `r` — refresh
81
+ - `q` or `esc` — close
82
+
83
+ ### `/notify-test`
70
84
 
71
85
  Run `/notify-test` inside pi to fire a test notification.
72
86
  Pass a string argument (e.g., `/notify-test hello`) to override the body.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi",
@@ -36,6 +36,7 @@
36
36
  "dependencies": {
37
37
  "@earendil-works/pi-tui": "0.80.7",
38
38
  "emittery": "^2.0.0",
39
+ "lodash-es": "^4.18.1",
39
40
  "node-notifier": "^10.0.1",
40
41
  "proper-lockfile": "^4.1.2"
41
42
  },
@@ -44,6 +45,7 @@
44
45
  },
45
46
  "devDependencies": {
46
47
  "@raidou/eslint-config-base": "^4.4.3",
48
+ "@types/lodash-es": "^4.17.12",
47
49
  "@types/node": "^22.0.0",
48
50
  "@types/proper-lockfile": "^4.1.4",
49
51
  "eslint": "^10.7.0",
package/src/config.ts CHANGED
@@ -13,7 +13,6 @@ interface NotifyConfig {
13
13
  readonly notifyTools?: readonly string[]
14
14
  readonly events?: NotifyEventsConfig
15
15
  readonly finished?: boolean
16
- readonly finishedThrottleSecs?: number
17
16
  readonly onlyNotifyWhenUnfocused?: boolean
18
17
  readonly unfocusedActivityThresholdSecs?: number
19
18
  readonly tmuxSymbol?: string
@@ -24,7 +23,6 @@ export interface ResolvedNotifyConfig {
24
23
  readonly notifyTools: ReadonlySet<string>
25
24
  readonly events: NotifyEventsConfig
26
25
  readonly finished: boolean
27
- readonly finishedThrottleMs: number
28
26
  readonly onlyNotifyWhenUnfocused: boolean
29
27
  readonly unfocusedActivityThresholdMs: number
30
28
  readonly tmuxSymbol: string
@@ -67,7 +65,6 @@ export function loadConfig(): ResolvedNotifyConfig {
67
65
  notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
68
66
  events,
69
67
  finished: cfg.finished ?? true,
70
- finishedThrottleMs: Math.max(0, (cfg.finishedThrottleSecs ?? 0) * 1000),
71
68
  onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
72
69
  unfocusedActivityThresholdMs: Math.max(
73
70
  0,
@@ -1,6 +1,6 @@
1
1
  import { Registrar } from '../shared/registrar.js'
2
2
  import { readSessions } from './state-store.js'
3
- import { createDashboard } from './ui.js'
3
+ import { Dashboard } from './ui/dashboard.js'
4
4
 
5
5
  export class DashboardCommand extends Registrar {
6
6
  protected override setup(): void {
@@ -11,7 +11,7 @@ export class DashboardCommand extends Registrar {
11
11
 
12
12
  await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
13
13
  let closed = false
14
- const dashboard = createDashboard({
14
+ const dashboard = new Dashboard({
15
15
  tui,
16
16
  theme,
17
17
  initialSessions,
@@ -24,12 +24,7 @@ export class DashboardCommand extends Registrar {
24
24
  },
25
25
  })
26
26
 
27
- return {
28
- render: dashboard.render.bind(dashboard),
29
- handleInput: dashboard.handleInput.bind(dashboard),
30
- invalidate: dashboard.invalidate.bind(dashboard),
31
- dispose: dashboard.dispose.bind(dashboard),
32
- }
27
+ return dashboard
33
28
  })
34
29
 
35
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`
@@ -1,11 +1,33 @@
1
- import { unlinkSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { rmSync, unlinkSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
3
 
4
4
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
- import { getAgentDir } from '@earendil-works/pi-coding-agent'
6
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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
+ })
7
26
 
8
- import type { StateTracker } from '../state-tracker.js'
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'
9
31
  import { SessionStore } from './session-store.js'
10
32
  import { readState, updateState } from './state-store.js'
11
33
 
@@ -113,8 +135,12 @@ const META = {
113
135
  projectName: 'test-project',
114
136
  }
115
137
 
138
+ afterAll(() => {
139
+ rmSync(dirname(STATE_FILE), { recursive: true, force: true })
140
+ })
141
+
116
142
  describe('SessionStore', () => {
117
- const testLockFile = join(getAgentDir(), 'pi-notify-test', 'state.json.lock')
143
+ const testLockFile = `${STATE_FILE}.lock`
118
144
 
119
145
  beforeEach(async () => {
120
146
  try {
@@ -122,7 +148,7 @@ describe('SessionStore', () => {
122
148
  } catch {
123
149
  // ignore
124
150
  }
125
- await updateState(() => ({ version: 1, sessions: {} }))
151
+ await updateState(() => ({ version: 2, sessions: {} }))
126
152
  })
127
153
 
128
154
  afterEach(() => {
@@ -145,9 +171,11 @@ describe('SessionStore', () => {
145
171
  )
146
172
  store.register(vi.fn())
147
173
 
148
- expect(emitSpy).toHaveBeenCalledTimes(2)
174
+ expect(emitSpy).toHaveBeenCalledTimes(4)
149
175
  expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
150
176
  expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
177
+ expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
178
+ expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
151
179
  expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
152
180
  })
153
181
 
@@ -287,4 +315,124 @@ describe('SessionStore', () => {
287
315
  store.stop()
288
316
  }).not.toThrow()
289
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
+ })
290
438
  })
@@ -5,13 +5,9 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
5
 
6
6
  import { Registrar } from '../shared/registrar.js'
7
7
  import type { StateTracker } from '../state-tracker.js'
8
- import type { SessionRecord } from './state-store.js'
8
+ import type { SessionRecord, SessionState } from './state-store.js'
9
9
  import { updateState } from './state-store.js'
10
10
 
11
- export interface SessionUpdate {
12
- state: 'running' | 'idle'
13
- }
14
-
15
11
  export class SessionStore extends Registrar {
16
12
  private readonly stateTracker: StateTracker
17
13
  private sessionId: string | undefined = undefined
@@ -23,7 +19,7 @@ export class SessionStore extends Registrar {
23
19
  this.stateTracker = stateTracker
24
20
  }
25
21
 
26
- private async saveSession(updates: SessionUpdate): Promise<void> {
22
+ private async saveSession(nextState: SessionState): Promise<void> {
27
23
  if (this.sessionId === undefined || this.meta === undefined) return
28
24
 
29
25
  const now = Date.now()
@@ -33,7 +29,9 @@ export class SessionStore extends Registrar {
33
29
 
34
30
  await updateState((state) => {
35
31
  const existing = state.sessions[id]
36
- const stateChanged = updates.state !== existing?.state
32
+ const isRunning = nextState === 'running'
33
+
34
+ const startRunningAt = isRunning ? now : undefined
37
35
 
38
36
  const record: SessionRecord = {
39
37
  pid: meta.pid,
@@ -41,8 +39,8 @@ export class SessionStore extends Registrar {
41
39
  cwd: meta.cwd,
42
40
  projectName: meta.projectName,
43
41
  startedAt: existing?.startedAt ?? now,
44
- state: updates.state,
45
- stateChangedAt: stateChanged ? now : existing.stateChangedAt,
42
+ state: nextState,
43
+ startedRunningAt: startRunningAt,
46
44
  }
47
45
 
48
46
  return {
@@ -68,15 +66,21 @@ export class SessionStore extends Registrar {
68
66
  const sessionId = ctx.sessionManager.getSessionId()
69
67
  this.sessionId = sessionId
70
68
  this.meta = meta
71
- void this.saveSession({ state: 'idle' })
69
+ void this.saveSession('idle')
72
70
  })
73
71
 
74
72
  this.unsubscribes.push(
75
73
  this.stateTracker.events.on('running', async () => {
76
- await this.saveSession({ state: 'running' })
74
+ await this.saveSession('running')
77
75
  }),
78
76
  this.stateTracker.events.on('idle', async () => {
79
- await this.saveSession({ state: 'idle' })
77
+ await this.saveSession('idle')
78
+ }),
79
+ this.stateTracker.events.on('tool', async ({ data }) => {
80
+ await this.saveSession(`tool_call:${data}`)
81
+ }),
82
+ this.stateTracker.events.on('event', async ({ data }) => {
83
+ await this.saveSession(`event:${data}`)
80
84
  }),
81
85
  )
82
86
  }
@@ -1,10 +1,89 @@
1
- import { unlinkSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { rmSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
3
 
4
- import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest'
6
13
 
7
- import { isProcessAlive, readSessions, updateState } from './state-store.js'
14
+ vi.mock('./consts.js', async () => {
15
+ const { mkdtempSync } = await import('node:fs')
16
+ const path = await import('node:path')
17
+ const { tmpdir } = await import('node:os')
18
+
19
+ const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-test-'))
20
+ return {
21
+ STATE_FILE: path.join(stateDir, 'state.json'),
22
+ STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
23
+ }
24
+ })
25
+
26
+ afterAll(() => {
27
+ rmSync(dirname(STATE_FILE), { recursive: true, force: true })
28
+ })
29
+
30
+ import { STATE_FILE } from './consts.js'
31
+ import {
32
+ isProcessAlive,
33
+ readSessions,
34
+ readState,
35
+ updateState,
36
+ } from './state-store.js'
37
+
38
+ describe('readState', () => {
39
+ const testLockFile = `${STATE_FILE}.lock`
40
+
41
+ afterEach(() => {
42
+ try {
43
+ unlinkSync(testLockFile)
44
+ unlinkSync(STATE_FILE)
45
+ } catch {
46
+ // ignore
47
+ }
48
+ })
49
+
50
+ it('writes state with version 2', async () => {
51
+ await updateState((state) => ({ ...state, sessions: {} }))
52
+ expect(readState().version).toBe(2)
53
+ })
54
+
55
+ it('reads v1 records as-is with absent v2 fields', () => {
56
+ const record = {
57
+ pid: process.pid,
58
+ sessionId: 'v1-session',
59
+ cwd: process.cwd(),
60
+ projectName: 'v1-project',
61
+ startedAt: Date.now(),
62
+ state: 'idle',
63
+ }
64
+ writeFileSync(
65
+ STATE_FILE,
66
+ JSON.stringify({
67
+ version: 1,
68
+ sessions: { [String(process.pid)]: record },
69
+ }),
70
+ 'utf8',
71
+ )
72
+
73
+ const state = readState()
74
+ expect(state.version).toBe(2)
75
+ expect(state.sessions[String(process.pid)]).toEqual(record)
76
+ })
77
+
78
+ it('drops malformed session records instead of crashing', () => {
79
+ writeFileSync(
80
+ STATE_FILE,
81
+ JSON.stringify({ version: 2, sessions: { x: null } }),
82
+ 'utf8',
83
+ )
84
+ expect(readState().sessions).toEqual({ x: undefined })
85
+ })
86
+ })
8
87
 
9
88
  describe('isProcessAlive', () => {
10
89
  it('returns true for current process pid', () => {
@@ -52,8 +131,7 @@ describe('isProcessAlive', () => {
52
131
  })
53
132
 
54
133
  describe('readSessions', () => {
55
- const testDir = join(getAgentDir(), 'pi-notify-test')
56
- const testLockFile = join(testDir, 'state.json.lock')
134
+ const testLockFile = `${STATE_FILE}.lock`
57
135
 
58
136
  beforeEach(async () => {
59
137
  try {
@@ -61,7 +139,7 @@ describe('readSessions', () => {
61
139
  } catch {
62
140
  // ignore
63
141
  }
64
- await updateState(() => ({ version: 1, sessions: {} }))
142
+ await updateState(() => ({ version: 2, sessions: {} }))
65
143
  })
66
144
 
67
145
  afterEach(() => {
@@ -90,7 +168,6 @@ describe('readSessions', () => {
90
168
  projectName: 'alive-project',
91
169
  startedAt: Date.now(),
92
170
  state: 'running',
93
- stateChangedAt: Date.now(),
94
171
  },
95
172
  [deadKey]: {
96
173
  pid: deadPid,
@@ -99,7 +176,6 @@ describe('readSessions', () => {
99
176
  projectName: 'dead-project',
100
177
  startedAt: Date.now(),
101
178
  state: 'running',
102
- stateChangedAt: Date.now(),
103
179
  },
104
180
  },
105
181
  }))
@@ -128,7 +204,6 @@ describe('readSessions', () => {
128
204
  projectName: 'test-project',
129
205
  startedAt: Date.now(),
130
206
  state: 'running',
131
- stateChangedAt: Date.now(),
132
207
  },
133
208
  },
134
209
  }))
@@ -140,8 +215,7 @@ describe('readSessions', () => {
140
215
  })
141
216
 
142
217
  describe('updateState', () => {
143
- const testDir = join(getAgentDir(), 'pi-notify-test')
144
- const testLockFile = join(testDir, 'state.json.lock')
218
+ const testLockFile = `${STATE_FILE}.lock`
145
219
 
146
220
  beforeEach(() => {
147
221
  try {
@@ -171,7 +245,6 @@ describe('updateState', () => {
171
245
  projectName: 'test-project',
172
246
  startedAt: Date.now(),
173
247
  state: 'running',
174
- stateChangedAt: Date.now(),
175
248
  },
176
249
  },
177
250
  }))
@@ -191,7 +264,6 @@ describe('updateState', () => {
191
264
  projectName: 'concurrent-test',
192
265
  startedAt: Date.now(),
193
266
  state: 'running',
194
- stateChangedAt: Date.now(),
195
267
  },
196
268
  },
197
269
  }))
@@ -204,7 +276,7 @@ describe('updateState', () => {
204
276
  [sessionId]: state.sessions[sessionId]
205
277
  ? {
206
278
  ...state.sessions[sessionId],
207
- stateChangedAt: Date.now(),
279
+ startedAt: Date.now(),
208
280
  }
209
281
  : undefined,
210
282
  },
@@ -216,7 +288,7 @@ describe('updateState', () => {
216
288
  [sessionId]: state.sessions[sessionId]
217
289
  ? {
218
290
  ...state.sessions[sessionId],
219
- stateChangedAt: Date.now() + 1,
291
+ startedAt: Date.now() + 1,
220
292
  }
221
293
  : undefined,
222
294
  },