@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.
@@ -0,0 +1,87 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { basename } from 'node:path'
3
+
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+
6
+ import { Registrar } from '../shared/registrar.js'
7
+ import type { StateTracker } from '../state-tracker.js'
8
+ import type { SessionRecord, SessionState } from './state-store.js'
9
+ import { updateState } from './state-store.js'
10
+
11
+ export class SessionStore extends Registrar {
12
+ private readonly stateTracker: StateTracker
13
+ private sessionId: string | undefined = undefined
14
+ private meta: { pid: number; cwd: string; projectName: string } | undefined =
15
+ undefined
16
+
17
+ constructor(pi: ExtensionAPI, stateTracker: StateTracker) {
18
+ super(pi)
19
+ this.stateTracker = stateTracker
20
+ }
21
+
22
+ private async saveSession(nextState: SessionState): Promise<void> {
23
+ if (this.sessionId === undefined || this.meta === undefined) return
24
+
25
+ const now = Date.now()
26
+ const meta = this.meta
27
+ const sessionId = this.sessionId
28
+ const id = String(meta.pid)
29
+
30
+ await updateState((state) => {
31
+ const existing = state.sessions[id]
32
+ const isRunning = nextState === 'running'
33
+
34
+ const startRunningAt = isRunning ? now : undefined
35
+
36
+ const record: SessionRecord = {
37
+ pid: meta.pid,
38
+ sessionId,
39
+ cwd: meta.cwd,
40
+ projectName: meta.projectName,
41
+ startedAt: existing?.startedAt ?? now,
42
+ state: nextState,
43
+ startedRunningAt: startRunningAt,
44
+ }
45
+
46
+ return {
47
+ ...state,
48
+ sessions: { ...state.sessions, [id]: record },
49
+ }
50
+ })
51
+ }
52
+
53
+ protected override setup(): void {
54
+ this.pi.on('session_start', (_event, ctx) => {
55
+ let normalizedCwd = ctx.cwd
56
+ try {
57
+ normalizedCwd = realpathSync(ctx.cwd)
58
+ } catch {
59
+ // Use original path if realpathSync fails
60
+ }
61
+ const meta = {
62
+ pid: process.pid,
63
+ cwd: normalizedCwd,
64
+ projectName: basename(normalizedCwd),
65
+ }
66
+ const sessionId = ctx.sessionManager.getSessionId()
67
+ this.sessionId = sessionId
68
+ this.meta = meta
69
+ void this.saveSession('idle')
70
+ })
71
+
72
+ this.unsubscribes.push(
73
+ this.stateTracker.events.on('running', async () => {
74
+ await this.saveSession('running')
75
+ }),
76
+ this.stateTracker.events.on('idle', async () => {
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}`)
84
+ }),
85
+ )
86
+ }
87
+ }
@@ -0,0 +1,303 @@
1
+ import { rmSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import {
5
+ afterAll,
6
+ afterEach,
7
+ beforeEach,
8
+ describe,
9
+ expect,
10
+ it,
11
+ vi,
12
+ } from 'vitest'
13
+
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
+ })
87
+
88
+ describe('isProcessAlive', () => {
89
+ it('returns true for current process pid', () => {
90
+ expect(isProcessAlive(process.pid)).toBe(true)
91
+ })
92
+
93
+ it('returns false for non-existent pid', () => {
94
+ expect(isProcessAlive(999999)).toBe(false)
95
+ })
96
+
97
+ it('returns false for pid out of range', () => {
98
+ expect(isProcessAlive(2147483647)).toBe(false)
99
+ })
100
+
101
+ it('returns false for invalid pid', () => {
102
+ expect(isProcessAlive(0)).toBe(false)
103
+ expect(isProcessAlive(-1)).toBe(false)
104
+ })
105
+
106
+ it('handles ESRCH gracefully', () => {
107
+ const error = new Error('process not found') as Error & { code: string }
108
+ error.code = 'ESRCH'
109
+ vi.spyOn(process, 'kill').mockImplementation(() => {
110
+ throw error
111
+ })
112
+ try {
113
+ expect(isProcessAlive(12345)).toBe(false)
114
+ } finally {
115
+ vi.restoreAllMocks()
116
+ }
117
+ })
118
+
119
+ it('returns true for EPERM (process exists but no permission)', () => {
120
+ const error = new Error('permission denied') as Error & { code: string }
121
+ error.code = 'EPERM'
122
+ vi.spyOn(process, 'kill').mockImplementation(() => {
123
+ throw error
124
+ })
125
+ try {
126
+ expect(isProcessAlive(1)).toBe(true)
127
+ } finally {
128
+ vi.restoreAllMocks()
129
+ }
130
+ })
131
+ })
132
+
133
+ describe('readSessions', () => {
134
+ const testLockFile = `${STATE_FILE}.lock`
135
+
136
+ beforeEach(async () => {
137
+ try {
138
+ unlinkSync(testLockFile)
139
+ } catch {
140
+ // ignore
141
+ }
142
+ await updateState(() => ({ version: 2, sessions: {} }))
143
+ })
144
+
145
+ afterEach(() => {
146
+ try {
147
+ unlinkSync(testLockFile)
148
+ } catch {
149
+ // ignore
150
+ }
151
+ })
152
+
153
+ it('keeps alive sessions and removes dead ones', async () => {
154
+ const aliveSessionId = `alive-${Date.now()}`
155
+ const deadSessionId = `dead-${Date.now()}`
156
+ const deadPid = 999999
157
+ const aliveKey = String(process.pid)
158
+ const deadKey = String(deadPid)
159
+
160
+ await updateState((state) => ({
161
+ ...state,
162
+ sessions: {
163
+ ...state.sessions,
164
+ [aliveKey]: {
165
+ pid: process.pid,
166
+ sessionId: aliveSessionId,
167
+ cwd: process.cwd(),
168
+ projectName: 'alive-project',
169
+ startedAt: Date.now(),
170
+ state: 'running',
171
+ },
172
+ [deadKey]: {
173
+ pid: deadPid,
174
+ sessionId: deadSessionId,
175
+ cwd: process.cwd(),
176
+ projectName: 'dead-project',
177
+ startedAt: Date.now(),
178
+ state: 'running',
179
+ },
180
+ },
181
+ }))
182
+
183
+ const alive = await readSessions()
184
+
185
+ expect(alive.some((s) => s.sessionId === aliveSessionId)).toBe(true)
186
+ expect(alive.every((s) => s.sessionId !== deadSessionId)).toBe(true)
187
+
188
+ const state = await import('./state-store.js').then((m) => m.readState())
189
+ expect(aliveKey in state.sessions).toBe(true)
190
+ expect(deadKey in state.sessions).toBe(false)
191
+ })
192
+
193
+ it('returns all sessions when none are dead', async () => {
194
+ const sessionId = `all-alive-${Date.now()}`
195
+
196
+ await updateState((state) => ({
197
+ ...state,
198
+ sessions: {
199
+ ...state.sessions,
200
+ [sessionId]: {
201
+ pid: process.pid,
202
+ sessionId,
203
+ cwd: process.cwd(),
204
+ projectName: 'test-project',
205
+ startedAt: Date.now(),
206
+ state: 'running',
207
+ },
208
+ },
209
+ }))
210
+
211
+ const alive = await readSessions()
212
+
213
+ expect(alive.some((s) => s.sessionId === sessionId)).toBe(true)
214
+ })
215
+ })
216
+
217
+ describe('updateState', () => {
218
+ const testLockFile = `${STATE_FILE}.lock`
219
+
220
+ beforeEach(() => {
221
+ try {
222
+ unlinkSync(testLockFile)
223
+ } catch {
224
+ // ignore
225
+ }
226
+ })
227
+
228
+ afterEach(() => {
229
+ try {
230
+ unlinkSync(testLockFile)
231
+ } catch {
232
+ // ignore
233
+ }
234
+ })
235
+
236
+ it('writes state and preserves other fields', async () => {
237
+ await updateState((state) => ({
238
+ ...state,
239
+ sessions: {
240
+ ...state.sessions,
241
+ 'test-session-1': {
242
+ pid: process.pid,
243
+ sessionId: 'test-session-1',
244
+ cwd: process.cwd(),
245
+ projectName: 'test-project',
246
+ startedAt: Date.now(),
247
+ state: 'running',
248
+ },
249
+ },
250
+ }))
251
+ })
252
+
253
+ it('handles concurrent updates correctly', async () => {
254
+ const sessionId = `test-concurrent-${Date.now()}`
255
+
256
+ await updateState((state) => ({
257
+ ...state,
258
+ sessions: {
259
+ ...state.sessions,
260
+ [sessionId]: {
261
+ pid: process.pid,
262
+ sessionId,
263
+ cwd: process.cwd(),
264
+ projectName: 'concurrent-test',
265
+ startedAt: Date.now(),
266
+ state: 'running',
267
+ },
268
+ },
269
+ }))
270
+
271
+ await Promise.all([
272
+ updateState((state) => ({
273
+ ...state,
274
+ sessions: {
275
+ ...state.sessions,
276
+ [sessionId]: state.sessions[sessionId]
277
+ ? {
278
+ ...state.sessions[sessionId],
279
+ startedAt: Date.now(),
280
+ }
281
+ : undefined,
282
+ },
283
+ })),
284
+ updateState((state) => ({
285
+ ...state,
286
+ sessions: {
287
+ ...state.sessions,
288
+ [sessionId]: state.sessions[sessionId]
289
+ ? {
290
+ ...state.sessions[sessionId],
291
+ startedAt: Date.now() + 1,
292
+ }
293
+ : undefined,
294
+ },
295
+ })),
296
+ ])
297
+
298
+ await updateState((state) => {
299
+ expect(state.sessions[sessionId]).toBeDefined()
300
+ return state
301
+ })
302
+ })
303
+ })
@@ -3,35 +3,76 @@ import {
3
3
  mkdirSync,
4
4
  readFileSync,
5
5
  renameSync,
6
- rmdirSync,
7
6
  writeFileSync,
8
7
  } from 'node:fs'
9
- import { dirname, join } from 'node:path'
8
+ import { dirname } from 'node:path'
10
9
 
11
- import { getAgentDir } from '@earendil-works/pi-coding-agent'
10
+ import { omit, partition } from 'lodash-es'
11
+ import lockfile from 'proper-lockfile'
12
+
13
+ import { STATE_FILE, STATE_TMP_FILE } from './consts.js'
14
+
15
+ const ESRCH = 'ESRCH'
16
+ const EPERM = 'EPERM'
17
+
18
+ export function isProcessAlive(pid: number): boolean {
19
+ if (pid <= 0) return false
20
+ if (pid === process.pid) return true
21
+
22
+ try {
23
+ process.kill(pid, 0)
24
+ return true
25
+ } catch (err: unknown) {
26
+ if (err instanceof Error && 'code' in err) {
27
+ const code = (err as { code: string }).code
28
+ if (code === EPERM) return true
29
+ if (code === ESRCH) return false
30
+ }
31
+ return false
32
+ }
33
+ }
34
+
35
+ export async function readSessions(): Promise<SessionRecord[]> {
36
+ const state = readState()
37
+ const sessions = Object.values(state.sessions).filter(
38
+ (session): session is SessionRecord => session !== undefined,
39
+ )
40
+ const [alive, dead] = partition(sessions, (session) =>
41
+ isProcessAlive(session.pid),
42
+ )
43
+
44
+ const deadIds = dead.map((session) => String(session.pid))
45
+
46
+ if (deadIds.length === 0) return alive
47
+
48
+ await updateState((s) => {
49
+ const sessions: typeof s.sessions = omit(s.sessions, deadIds)
50
+ return { ...s, sessions }
51
+ })
52
+
53
+ return alive
54
+ }
55
+
56
+ export type SessionState =
57
+ 'running' | 'idle' | `tool_call:${string}` | `event:${string}`
12
58
 
13
59
  export interface SessionRecord {
14
60
  pid: number
61
+ sessionId: string
15
62
  cwd: string
16
63
  projectName: string
17
64
  startedAt: number
18
- lastHeartbeatAt: number
19
- state: 'running' | 'idle'
20
- stateChangedAt: number
21
- model: string | undefined
22
- lastEvent: { type: string; summary: string; at: number } | undefined
65
+ state: SessionState
66
+ startedRunningAt?: number
23
67
  }
24
68
 
25
69
  export interface DashboardState {
26
- version: 1
70
+ version: 2
27
71
  sessions: Record<string, SessionRecord | undefined>
28
72
  }
29
73
 
30
- const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
31
- const LOCK_FILE = `${STATE_FILE}.lock`
32
- const LOCK_RETRY_DELAY_MS = 50
74
+ const LOCK_RETRY_INTERVAL_MS = 50
33
75
  const LOCK_MAX_RETRIES = 20
34
- const STALE_THRESHOLD_MS = 30000
35
76
 
36
77
  function ensureStateDir(): void {
37
78
  const dir = dirname(STATE_FILE)
@@ -40,117 +81,101 @@ function ensureStateDir(): void {
40
81
  }
41
82
  }
42
83
 
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
- }
84
+ ensureStateDir()
85
+
86
+ function parseSessionRecord(value: unknown): SessionRecord | undefined {
87
+ if (typeof value !== 'object' || value === null) return undefined
88
+ const record = value as Record<string, unknown>
89
+ if (
90
+ typeof record.pid !== 'number' ||
91
+ typeof record.sessionId !== 'string' ||
92
+ typeof record.cwd !== 'string' ||
93
+ typeof record.projectName !== 'string' ||
94
+ typeof record.startedAt !== 'number' ||
95
+ !isSessionState(record.state)
96
+ ) {
97
+ return undefined
98
+ }
99
+ return {
100
+ pid: record.pid,
101
+ sessionId: record.sessionId,
102
+ cwd: record.cwd,
103
+ projectName: record.projectName,
104
+ startedAt: record.startedAt,
105
+ state: record.state,
106
+ startedRunningAt:
107
+ typeof record.startedRunningAt === 'number'
108
+ ? record.startedRunningAt
109
+ : undefined,
64
110
  }
65
- return false
66
111
  }
67
112
 
68
- function releaseLock(): void {
69
- try {
70
- rmdirSync(LOCK_FILE)
71
- } catch {
72
- // Lock file may not exist
73
- }
113
+ function isActivityState(
114
+ value: string,
115
+ ): value is `tool_call:${string}` | `event:${string}` {
116
+ return value.startsWith('tool_call:') || value.startsWith('event:')
74
117
  }
75
118
 
76
- function isPidAlive(pid: number): boolean {
119
+ function isSessionState(value: unknown): value is SessionState {
120
+ return (
121
+ value === 'running' ||
122
+ value === 'idle' ||
123
+ (typeof value === 'string' && isActivityState(value))
124
+ )
125
+ }
126
+
127
+ function parseState(data: string): DashboardState | undefined {
128
+ let parsed: unknown
77
129
  try {
78
- process.kill(pid, 0)
79
- return true
130
+ parsed = JSON.parse(data)
80
131
  } catch {
81
- return false
132
+ return undefined
82
133
  }
134
+ if (typeof parsed !== 'object' || parsed === null) return undefined
135
+ const raw = parsed as Record<string, unknown>
136
+ if (typeof raw.sessions !== 'object' || raw.sessions === null)
137
+ return undefined
138
+
139
+ const sessions: Record<string, SessionRecord | undefined> = {}
140
+ for (const [key, value] of Object.entries(raw.sessions)) {
141
+ sessions[key] = parseSessionRecord(value)
142
+ }
143
+ // Records from earlier versions without optional fields parse as-is.
144
+ return { version: 2, sessions }
83
145
  }
84
146
 
85
- export function readState(options?: { filterStale?: boolean }): DashboardState {
86
- ensureStateDir()
147
+ export function readState(): DashboardState {
87
148
  if (!existsSync(STATE_FILE)) {
88
- return { version: 1, sessions: {} }
149
+ return { version: 2, sessions: {} }
89
150
  }
90
151
 
91
- let state: DashboardState
92
152
  try {
93
- const data = readFileSync(STATE_FILE, 'utf8')
94
- state = JSON.parse(data) as DashboardState
153
+ const state = parseState(readFileSync(STATE_FILE, 'utf8'))
154
+ return state ?? { version: 2, sessions: {} }
95
155
  } catch {
96
- return { version: 1, sessions: {} }
97
- }
98
-
99
- if (options?.filterStale === false) {
100
- return state
156
+ return { version: 2, sessions: {} }
101
157
  }
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
158
  }
112
159
 
113
160
  export async function updateState(
114
161
  mutator: (state: DashboardState) => DashboardState,
115
162
  ): Promise<void> {
116
- ensureStateDir()
117
-
118
- const locked = await acquireLock()
119
- if (!locked) {
120
- throw new Error('Failed to acquire lock for state update')
121
- }
163
+ const release = await lockfile.lock(STATE_FILE, {
164
+ realpath: false,
165
+ stale: 30000,
166
+ retries: {
167
+ minTimeout: LOCK_RETRY_INTERVAL_MS,
168
+ maxTimeout: LOCK_RETRY_INTERVAL_MS,
169
+ retries: LOCK_MAX_RETRIES,
170
+ },
171
+ })
122
172
 
123
173
  try {
124
174
  const state = readState()
125
175
  const newState = mutator(state)
126
- const tmpFile = `${STATE_FILE}.tmp`
127
- writeFileSync(tmpFile, JSON.stringify(newState, null, 2), 'utf8')
128
- renameSync(tmpFile, STATE_FILE)
176
+ writeFileSync(STATE_TMP_FILE, JSON.stringify(newState, null, 2), 'utf8')
177
+ renameSync(STATE_TMP_FILE, STATE_FILE)
129
178
  } finally {
130
- releaseLock()
179
+ await release()
131
180
  }
132
181
  }
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
- }