@raidou/pi-notify 0.3.0 → 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.
@@ -0,0 +1,231 @@
1
+ import { unlinkSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ import { isProcessAlive, readSessions, updateState } from './state-store.js'
8
+
9
+ describe('isProcessAlive', () => {
10
+ it('returns true for current process pid', () => {
11
+ expect(isProcessAlive(process.pid)).toBe(true)
12
+ })
13
+
14
+ it('returns false for non-existent pid', () => {
15
+ expect(isProcessAlive(999999)).toBe(false)
16
+ })
17
+
18
+ it('returns false for pid out of range', () => {
19
+ expect(isProcessAlive(2147483647)).toBe(false)
20
+ })
21
+
22
+ it('returns false for invalid pid', () => {
23
+ expect(isProcessAlive(0)).toBe(false)
24
+ expect(isProcessAlive(-1)).toBe(false)
25
+ })
26
+
27
+ it('handles ESRCH gracefully', () => {
28
+ const error = new Error('process not found') as Error & { code: string }
29
+ error.code = 'ESRCH'
30
+ vi.spyOn(process, 'kill').mockImplementation(() => {
31
+ throw error
32
+ })
33
+ try {
34
+ expect(isProcessAlive(12345)).toBe(false)
35
+ } finally {
36
+ vi.restoreAllMocks()
37
+ }
38
+ })
39
+
40
+ it('returns true for EPERM (process exists but no permission)', () => {
41
+ const error = new Error('permission denied') as Error & { code: string }
42
+ error.code = 'EPERM'
43
+ vi.spyOn(process, 'kill').mockImplementation(() => {
44
+ throw error
45
+ })
46
+ try {
47
+ expect(isProcessAlive(1)).toBe(true)
48
+ } finally {
49
+ vi.restoreAllMocks()
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('readSessions', () => {
55
+ const testDir = join(getAgentDir(), 'pi-notify-test')
56
+ const testLockFile = join(testDir, 'state.json.lock')
57
+
58
+ beforeEach(async () => {
59
+ try {
60
+ unlinkSync(testLockFile)
61
+ } catch {
62
+ // ignore
63
+ }
64
+ await updateState(() => ({ version: 1, sessions: {} }))
65
+ })
66
+
67
+ afterEach(() => {
68
+ try {
69
+ unlinkSync(testLockFile)
70
+ } catch {
71
+ // ignore
72
+ }
73
+ })
74
+
75
+ it('keeps alive sessions and removes dead ones', async () => {
76
+ const aliveSessionId = `alive-${Date.now()}`
77
+ const deadSessionId = `dead-${Date.now()}`
78
+ const deadPid = 999999
79
+ const aliveKey = String(process.pid)
80
+ const deadKey = String(deadPid)
81
+
82
+ await updateState((state) => ({
83
+ ...state,
84
+ sessions: {
85
+ ...state.sessions,
86
+ [aliveKey]: {
87
+ pid: process.pid,
88
+ sessionId: aliveSessionId,
89
+ cwd: process.cwd(),
90
+ projectName: 'alive-project',
91
+ startedAt: Date.now(),
92
+ state: 'running',
93
+ stateChangedAt: Date.now(),
94
+ },
95
+ [deadKey]: {
96
+ pid: deadPid,
97
+ sessionId: deadSessionId,
98
+ cwd: process.cwd(),
99
+ projectName: 'dead-project',
100
+ startedAt: Date.now(),
101
+ state: 'running',
102
+ stateChangedAt: Date.now(),
103
+ },
104
+ },
105
+ }))
106
+
107
+ const alive = await readSessions()
108
+
109
+ expect(alive.some((s) => s.sessionId === aliveSessionId)).toBe(true)
110
+ expect(alive.every((s) => s.sessionId !== deadSessionId)).toBe(true)
111
+
112
+ const state = await import('./state-store.js').then((m) => m.readState())
113
+ expect(aliveKey in state.sessions).toBe(true)
114
+ expect(deadKey in state.sessions).toBe(false)
115
+ })
116
+
117
+ it('returns all sessions when none are dead', async () => {
118
+ const sessionId = `all-alive-${Date.now()}`
119
+
120
+ await updateState((state) => ({
121
+ ...state,
122
+ sessions: {
123
+ ...state.sessions,
124
+ [sessionId]: {
125
+ pid: process.pid,
126
+ sessionId,
127
+ cwd: process.cwd(),
128
+ projectName: 'test-project',
129
+ startedAt: Date.now(),
130
+ state: 'running',
131
+ stateChangedAt: Date.now(),
132
+ },
133
+ },
134
+ }))
135
+
136
+ const alive = await readSessions()
137
+
138
+ expect(alive.some((s) => s.sessionId === sessionId)).toBe(true)
139
+ })
140
+ })
141
+
142
+ describe('updateState', () => {
143
+ const testDir = join(getAgentDir(), 'pi-notify-test')
144
+ const testLockFile = join(testDir, 'state.json.lock')
145
+
146
+ beforeEach(() => {
147
+ try {
148
+ unlinkSync(testLockFile)
149
+ } catch {
150
+ // ignore
151
+ }
152
+ })
153
+
154
+ afterEach(() => {
155
+ try {
156
+ unlinkSync(testLockFile)
157
+ } catch {
158
+ // ignore
159
+ }
160
+ })
161
+
162
+ it('writes state and preserves other fields', async () => {
163
+ await updateState((state) => ({
164
+ ...state,
165
+ sessions: {
166
+ ...state.sessions,
167
+ 'test-session-1': {
168
+ pid: process.pid,
169
+ sessionId: 'test-session-1',
170
+ cwd: process.cwd(),
171
+ projectName: 'test-project',
172
+ startedAt: Date.now(),
173
+ state: 'running',
174
+ stateChangedAt: Date.now(),
175
+ },
176
+ },
177
+ }))
178
+ })
179
+
180
+ it('handles concurrent updates correctly', async () => {
181
+ const sessionId = `test-concurrent-${Date.now()}`
182
+
183
+ await updateState((state) => ({
184
+ ...state,
185
+ sessions: {
186
+ ...state.sessions,
187
+ [sessionId]: {
188
+ pid: process.pid,
189
+ sessionId,
190
+ cwd: process.cwd(),
191
+ projectName: 'concurrent-test',
192
+ startedAt: Date.now(),
193
+ state: 'running',
194
+ stateChangedAt: Date.now(),
195
+ },
196
+ },
197
+ }))
198
+
199
+ await Promise.all([
200
+ updateState((state) => ({
201
+ ...state,
202
+ sessions: {
203
+ ...state.sessions,
204
+ [sessionId]: state.sessions[sessionId]
205
+ ? {
206
+ ...state.sessions[sessionId],
207
+ stateChangedAt: Date.now(),
208
+ }
209
+ : undefined,
210
+ },
211
+ })),
212
+ updateState((state) => ({
213
+ ...state,
214
+ sessions: {
215
+ ...state.sessions,
216
+ [sessionId]: state.sessions[sessionId]
217
+ ? {
218
+ ...state.sessions[sessionId],
219
+ stateChangedAt: Date.now() + 1,
220
+ }
221
+ : undefined,
222
+ },
223
+ })),
224
+ ])
225
+
226
+ await updateState((state) => {
227
+ expect(state.sessions[sessionId]).toBeDefined()
228
+ return state
229
+ })
230
+ })
231
+ })
@@ -3,23 +3,70 @@ import {
3
3
  mkdirSync,
4
4
  readFileSync,
5
5
  renameSync,
6
- rmdirSync,
7
6
  writeFileSync,
8
7
  } from 'node:fs'
9
8
  import { dirname, join } from 'node:path'
10
9
 
11
10
  import { getAgentDir } from '@earendil-works/pi-coding-agent'
11
+ import lockfile from 'proper-lockfile'
12
+
13
+ const ESRCH = 'ESRCH'
14
+ const EPERM = 'EPERM'
15
+
16
+ export function isProcessAlive(pid: number): boolean {
17
+ if (pid <= 0) return false
18
+ if (pid === process.pid) return true
19
+
20
+ try {
21
+ process.kill(pid, 0)
22
+ return true
23
+ } catch (err: unknown) {
24
+ if (err instanceof Error && 'code' in err) {
25
+ const code = (err as { code: string }).code
26
+ if (code === EPERM) return true
27
+ if (code === ESRCH) return false
28
+ }
29
+ return false
30
+ }
31
+ }
32
+
33
+ export async function readSessions(): Promise<SessionRecord[]> {
34
+ const state = readState()
35
+ const deadIds: string[] = []
36
+ const alive: SessionRecord[] = []
37
+
38
+ for (const session of Object.values(state.sessions)) {
39
+ if (!session) continue
40
+ if (isProcessAlive(session.pid)) {
41
+ alive.push(session)
42
+ } else {
43
+ deadIds.push(String(session.pid))
44
+ }
45
+ }
46
+
47
+ if (deadIds.length === 0) return alive
48
+
49
+ await updateState((s) => {
50
+ const sessions: typeof s.sessions = {}
51
+ for (const key of Object.keys(s.sessions)) {
52
+ if (!deadIds.includes(key)) {
53
+ sessions[key] = s.sessions[key]
54
+ }
55
+ }
56
+ return { ...s, sessions }
57
+ })
58
+
59
+ return alive
60
+ }
12
61
 
13
62
  export interface SessionRecord {
14
63
  pid: number
64
+ sessionId: string
15
65
  cwd: string
16
66
  projectName: string
17
67
  startedAt: number
18
- lastHeartbeatAt: number
19
68
  state: 'running' | 'idle'
20
69
  stateChangedAt: number
21
- model: string | undefined
22
- lastEvent: { type: string; summary: string; at: number } | undefined
23
70
  }
24
71
 
25
72
  export interface DashboardState {
@@ -28,10 +75,8 @@ export interface DashboardState {
28
75
  }
29
76
 
30
77
  const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
31
- const LOCK_FILE = `${STATE_FILE}.lock`
32
- const LOCK_RETRY_DELAY_MS = 50
78
+ const LOCK_RETRY_INTERVAL_MS = 50
33
79
  const LOCK_MAX_RETRIES = 20
34
- const STALE_THRESHOLD_MS = 30000
35
80
 
36
81
  function ensureStateDir(): void {
37
82
  const dir = dirname(STATE_FILE)
@@ -40,74 +85,17 @@ function ensureStateDir(): void {
40
85
  }
41
86
  }
42
87
 
43
- async function acquireLock(retries = LOCK_MAX_RETRIES): Promise<boolean> {
44
- for (let i = 0; i < retries; i++) {
45
- try {
46
- mkdirSync(LOCK_FILE, { recursive: false })
47
- return true
48
- } catch (e) {
49
- const code =
50
- e instanceof Error && 'code' in e
51
- ? (e as NodeJS.ErrnoException).code
52
- : undefined
53
- if (code === 'EEXIST') {
54
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
55
- continue
56
- }
57
- if (code === 'ENOENT') {
58
- ensureStateDir()
59
- await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
60
- continue
61
- }
62
- throw e
63
- }
64
- }
65
- return false
66
- }
67
-
68
- function releaseLock(): void {
69
- try {
70
- rmdirSync(LOCK_FILE)
71
- } catch {
72
- // Lock file may not exist
73
- }
74
- }
75
-
76
- function isPidAlive(pid: number): boolean {
77
- try {
78
- process.kill(pid, 0)
79
- return true
80
- } catch {
81
- return false
82
- }
83
- }
84
-
85
- export function readState(options?: { filterStale?: boolean }): DashboardState {
86
- ensureStateDir()
88
+ export function readState(): DashboardState {
87
89
  if (!existsSync(STATE_FILE)) {
88
90
  return { version: 1, sessions: {} }
89
91
  }
90
92
 
91
- let state: DashboardState
92
93
  try {
93
94
  const data = readFileSync(STATE_FILE, 'utf8')
94
- state = JSON.parse(data) as DashboardState
95
+ return JSON.parse(data) as DashboardState
95
96
  } catch {
96
97
  return { version: 1, sessions: {} }
97
98
  }
98
-
99
- if (options?.filterStale === false) {
100
- return state
101
- }
102
-
103
- return cleanupStale(state).cleaned
104
- }
105
-
106
- export function readSessions(): SessionRecord[] {
107
- const state = readState()
108
- return Object.values(state.sessions).filter(
109
- (s): s is SessionRecord => s !== undefined,
110
- )
111
99
  }
112
100
 
113
101
  export async function updateState(
@@ -115,10 +103,15 @@ export async function updateState(
115
103
  ): Promise<void> {
116
104
  ensureStateDir()
117
105
 
118
- const locked = await acquireLock()
119
- if (!locked) {
120
- throw new Error('Failed to acquire lock for state update')
121
- }
106
+ const release = await lockfile.lock(STATE_FILE, {
107
+ realpath: false,
108
+ stale: 30000,
109
+ retries: {
110
+ minTimeout: LOCK_RETRY_INTERVAL_MS,
111
+ maxTimeout: LOCK_RETRY_INTERVAL_MS,
112
+ retries: LOCK_MAX_RETRIES,
113
+ },
114
+ })
122
115
 
123
116
  try {
124
117
  const state = readState()
@@ -127,30 +120,6 @@ export async function updateState(
127
120
  writeFileSync(tmpFile, JSON.stringify(newState, null, 2), 'utf8')
128
121
  renameSync(tmpFile, STATE_FILE)
129
122
  } finally {
130
- releaseLock()
123
+ await release()
131
124
  }
132
125
  }
133
-
134
- function cleanupStale(
135
- state: DashboardState,
136
- staleThresholdMs = STALE_THRESHOLD_MS,
137
- ): { cleaned: DashboardState; removedIds: string[] } {
138
- const now = Date.now()
139
- const removedIds: string[] = []
140
- const sessions: Record<string, SessionRecord | undefined> = {}
141
-
142
- for (const [id, record] of Object.entries(state.sessions)) {
143
- if (!record) continue
144
- const heartbeatAge = now - record.lastHeartbeatAt
145
- const isStale = heartbeatAge > staleThresholdMs
146
- const isPidAliveValue = isPidAlive(record.pid)
147
-
148
- if (!isStale && isPidAliveValue) {
149
- sessions[id] = record
150
- } else {
151
- removedIds.push(id)
152
- }
153
- }
154
-
155
- return { cleaned: { version: 1, sessions }, removedIds }
156
- }
@@ -12,23 +12,14 @@ import {
12
12
  import type { SessionRecord } from './state-store.js'
13
13
 
14
14
  const COLUMNS = [
15
+ { name: 'SESSION_ID', width: 8 },
15
16
  { name: 'PID', width: 8 },
16
17
  { name: 'STATE', width: 8 },
17
18
  { name: 'PROJECT', width: 15 },
18
19
  { name: 'UPTIME', width: 10 },
19
- { name: 'MODEL', width: 20 },
20
- { name: 'LAST EVENT', width: undefined },
21
20
  ] as const
22
21
 
23
- const [PID_COL, STATE_COL, PROJECT_COL, UPTIME_COL, MODEL_COL] = COLUMNS
24
-
25
- const FIXED_COLUMNS_WIDTH =
26
- PID_COL.width +
27
- STATE_COL.width +
28
- PROJECT_COL.width +
29
- UPTIME_COL.width +
30
- MODEL_COL.width
31
- const GAPS_WIDTH = (COLUMNS.length - 1) * 2
22
+ const [SESSION_ID_COL, PID_COL, STATE_COL, PROJECT_COL, UPTIME_COL] = COLUMNS
32
23
 
33
24
  const MAX_ROWS = Math.max(1, (process.stdout.rows || 20) - 6)
34
25
 
@@ -38,6 +29,7 @@ export interface DashboardProps {
38
29
  initialSessions: SessionRecord[]
39
30
  onRefresh: () => Promise<SessionRecord[]>
40
31
  onClose: () => void
32
+ onDispose?: () => void
41
33
  }
42
34
 
43
35
  function formatUptime(startedAt: number): string {
@@ -62,11 +54,27 @@ export function createDashboard(props: DashboardProps) {
62
54
  let scrollOffset = 0
63
55
  let cachedWidth: number | null = null
64
56
  let cachedLines: string[] = []
57
+ let disposed = false
65
58
 
66
59
  const dashboardContainer = new Container()
67
60
 
68
- function updateChildren(width: number): void {
69
- const remainingWidth = Math.max(1, width - FIXED_COLUMNS_WIDTH - GAPS_WIDTH)
61
+ function refresh(): void {
62
+ props
63
+ .onRefresh()
64
+ .then((newSessions) => {
65
+ sessions = [...newSessions]
66
+ scrollOffset = 0
67
+ cachedWidth = null
68
+ props.tui.requestRender()
69
+ })
70
+ .catch(() => {})
71
+ }
72
+
73
+ const timer = setInterval(() => {
74
+ if (!disposed) refresh()
75
+ }, 1000)
76
+
77
+ function updateChildren(): void {
70
78
  const { theme } = props
71
79
 
72
80
  const stats = sessions.reduce(
@@ -77,9 +85,9 @@ export function createDashboard(props: DashboardProps) {
77
85
  { running: 0, idle: 0 },
78
86
  )
79
87
 
80
- const headerLine = COLUMNS.map((col) =>
81
- col.width === undefined ? col.name : col.name.padEnd(col.width),
82
- ).join(' ')
88
+ const headerLine = COLUMNS.map((col) => col.name.padEnd(col.width)).join(
89
+ ' ',
90
+ )
83
91
 
84
92
  dashboardContainer.clear()
85
93
  dashboardContainer.addChild(
@@ -106,6 +114,10 @@ export function createDashboard(props: DashboardProps) {
106
114
  )) {
107
115
  const stateColor = session.state === 'running' ? 'success' : 'muted'
108
116
  const line = [
117
+ theme.fg(
118
+ 'dim',
119
+ session.sessionId.slice(-6).padEnd(SESSION_ID_COL.width),
120
+ ),
109
121
  theme.fg('dim', String(session.pid).padEnd(PID_COL.width)),
110
122
  theme.fg(stateColor, session.state.padEnd(STATE_COL.width)),
111
123
  theme.fg(
@@ -116,21 +128,6 @@ export function createDashboard(props: DashboardProps) {
116
128
  'dim',
117
129
  formatUptime(session.startedAt).padEnd(UPTIME_COL.width),
118
130
  ),
119
- theme.fg(
120
- 'muted',
121
- session.model
122
- ? truncateToWidth(session.model, MODEL_COL.width, '…', true)
123
- : '',
124
- ),
125
- theme.fg(
126
- 'dim',
127
- session.lastEvent
128
- ? truncateToWidth(
129
- `${session.lastEvent.type}:${session.lastEvent.summary}`,
130
- remainingWidth,
131
- )
132
- : '',
133
- ),
134
131
  ].join(' ')
135
132
  dashboardContainer.addChild(new Text(line, 0, 0))
136
133
  }
@@ -144,7 +141,7 @@ export function createDashboard(props: DashboardProps) {
144
141
  const component = {
145
142
  render(width: number): string[] {
146
143
  if (cachedWidth !== width) {
147
- updateChildren(width)
144
+ updateChildren()
148
145
  cachedLines = dashboardContainer.render(width)
149
146
  cachedWidth = width
150
147
  }
@@ -153,15 +150,7 @@ export function createDashboard(props: DashboardProps) {
153
150
 
154
151
  handleInput(data: string): void {
155
152
  if (matchesKey(data, 'r')) {
156
- props
157
- .onRefresh()
158
- .then((newSessions) => {
159
- sessions = [...newSessions]
160
- scrollOffset = 0
161
- cachedWidth = null
162
- props.tui.requestRender()
163
- })
164
- .catch(() => {})
153
+ refresh()
165
154
  return
166
155
  }
167
156
 
@@ -192,6 +181,12 @@ export function createDashboard(props: DashboardProps) {
192
181
  cachedWidth = null
193
182
  dashboardContainer.invalidate()
194
183
  },
184
+
185
+ dispose(): void {
186
+ disposed = true
187
+ clearInterval(timer)
188
+ props.onDispose?.()
189
+ },
195
190
  }
196
191
 
197
192
  return component
@@ -0,0 +1,115 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import type { ResolvedNotifyConfig } from './config.js'
5
+ import { EventsNotifier } from './events.js'
6
+
7
+ type EventsListener = (payload: unknown) => void
8
+
9
+ interface FakePi {
10
+ eventListeners: Map<string, Set<EventsListener>>
11
+ listeners: Map<string, Set<(...args: unknown[]) => void>>
12
+ events: { on(event: string, listener: EventsListener): () => void }
13
+ on(event: string, listener: (...args: unknown[]) => void): void
14
+ }
15
+
16
+ function makeFakePi(): FakePi {
17
+ const eventListeners = new Map<string, Set<EventsListener>>()
18
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
19
+ return {
20
+ eventListeners,
21
+ listeners,
22
+ events: {
23
+ on(event, listener) {
24
+ let set = eventListeners.get(event)
25
+ if (!set) {
26
+ set = new Set()
27
+ eventListeners.set(event, set)
28
+ }
29
+ set.add(listener)
30
+ return () => {
31
+ set.delete(listener)
32
+ }
33
+ },
34
+ },
35
+ on(event, listener) {
36
+ let set = listeners.get(event)
37
+ if (!set) {
38
+ set = new Set()
39
+ listeners.set(event, set)
40
+ }
41
+ set.add(listener)
42
+ },
43
+ }
44
+ }
45
+
46
+ function makeConfig(
47
+ events: ResolvedNotifyConfig['events'],
48
+ ): ResolvedNotifyConfig {
49
+ return {
50
+ enabled: true,
51
+ notifyTools: new Set<string>(),
52
+ events,
53
+ finished: true,
54
+ finishedThrottleMs: 0,
55
+ onlyNotifyWhenUnfocused: true,
56
+ unfocusedActivityThresholdMs: 0,
57
+ tmuxSymbol: '',
58
+ }
59
+ }
60
+
61
+ function emitEvent(pi: FakePi, event: string, payload?: unknown): void {
62
+ const set = pi.eventListeners.get(event)
63
+ if (!set) return
64
+ for (const listener of [...set]) listener(payload)
65
+ }
66
+
67
+ describe('EventsNotifier', () => {
68
+ it('notifies with the configured message when a custom event fires', () => {
69
+ const pi = makeFakePi()
70
+ const notifier = new EventsNotifier(
71
+ pi as unknown as ExtensionAPI,
72
+ makeConfig({
73
+ 'my:custom:event': 'Custom event triggered',
74
+ }),
75
+ )
76
+ const bodies: string[] = []
77
+ notifier.register((body) => bodies.push(body))
78
+
79
+ emitEvent(pi, 'my:custom:event')
80
+
81
+ expect(bodies).toEqual(['Custom event triggered'])
82
+ })
83
+
84
+ it('does not notify for events disabled with false', () => {
85
+ const pi = makeFakePi()
86
+ const notifier = new EventsNotifier(
87
+ pi as unknown as ExtensionAPI,
88
+ makeConfig({
89
+ 'my:custom:event': false,
90
+ }),
91
+ )
92
+ const bodies: string[] = []
93
+ notifier.register((body) => bodies.push(body))
94
+
95
+ emitEvent(pi, 'my:custom:event')
96
+
97
+ expect(bodies).toEqual([])
98
+ })
99
+
100
+ it('does not notify for events disabled with an empty string (backward compatibility)', () => {
101
+ const pi = makeFakePi()
102
+ const notifier = new EventsNotifier(
103
+ pi as unknown as ExtensionAPI,
104
+ makeConfig({
105
+ 'my:custom:event': '',
106
+ }),
107
+ )
108
+ const bodies: string[] = []
109
+ notifier.register((body) => bodies.push(body))
110
+
111
+ emitEvent(pi, 'my:custom:event')
112
+
113
+ expect(bodies).toEqual([])
114
+ })
115
+ })