@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.
- package/README.md +24 -38
- package/package.json +11 -6
- package/src/config.ts +2 -1
- package/src/dashboard/command.ts +9 -7
- package/src/dashboard/session-store.test.ts +290 -0
- package/src/dashboard/session-store.ts +83 -0
- package/src/dashboard/state-store.test.ts +231 -0
- package/src/dashboard/state-store.ts +64 -95
- package/src/dashboard/ui.ts +36 -41
- package/src/events.test.ts +115 -0
- package/src/events.ts +10 -25
- package/src/focus.ts +25 -25
- package/src/idle.ts +22 -33
- package/src/index.ts +19 -12
- package/src/jobs.ts +5 -22
- package/src/notify-test.ts +12 -8
- package/src/shared/registrar.test.ts +137 -0
- package/src/shared/registrar.ts +30 -0
- package/src/{types.ts → shared/types.ts} +4 -0
- package/src/state-tracker.test.ts +145 -0
- package/src/state-tracker.ts +76 -0
- package/src/states.ts +3 -1
- package/src/tmux-title.ts +14 -12
- package/src/tool.ts +10 -7
- package/src/dashboard/state-tracker.ts +0 -174
- /package/src/{utils.ts → shared/utils.ts} +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
}
|
package/src/dashboard/ui.ts
CHANGED
|
@@ -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
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
82
|
-
)
|
|
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(
|
|
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
|
-
|
|
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
|
+
})
|