@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 +33 -33
- package/package.json +13 -6
- package/src/config.ts +2 -4
- package/src/dashboard/command.ts +11 -14
- package/src/dashboard/consts.ts +6 -0
- package/src/dashboard/session-store.test.ts +438 -0
- package/src/dashboard/session-store.ts +87 -0
- package/src/dashboard/state-store.test.ts +303 -0
- package/src/dashboard/state-store.ts +124 -99
- package/src/dashboard/ui/columns.ts +102 -0
- package/src/dashboard/ui/dashboard.test.ts +118 -0
- package/src/dashboard/ui/dashboard.ts +122 -0
- package/src/focus.ts +25 -25
- package/src/index.ts +16 -18
- 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 +360 -0
- package/src/state-tracker.ts +123 -0
- package/src/states.ts +3 -1
- package/src/tmux-title.ts +12 -10
- package/src/dashboard/state-tracker.ts +0 -174
- package/src/dashboard/ui.ts +0 -198
- package/src/events.ts +0 -46
- package/src/idle.ts +0 -52
- package/src/tool.ts +0 -18
- /package/src/{utils.ts → shared/utils.ts} +0 -0
|
@@ -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
|
|
8
|
+
import { dirname } from 'node:path'
|
|
10
9
|
|
|
11
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
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:
|
|
70
|
+
version: 2
|
|
27
71
|
sessions: Record<string, SessionRecord | undefined>
|
|
28
72
|
}
|
|
29
73
|
|
|
30
|
-
const
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
79
|
-
return true
|
|
130
|
+
parsed = JSON.parse(data)
|
|
80
131
|
} catch {
|
|
81
|
-
return
|
|
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(
|
|
86
|
-
ensureStateDir()
|
|
147
|
+
export function readState(): DashboardState {
|
|
87
148
|
if (!existsSync(STATE_FILE)) {
|
|
88
|
-
return { version:
|
|
149
|
+
return { version: 2, sessions: {} }
|
|
89
150
|
}
|
|
90
151
|
|
|
91
|
-
let state: DashboardState
|
|
92
152
|
try {
|
|
93
|
-
const
|
|
94
|
-
state
|
|
153
|
+
const state = parseState(readFileSync(STATE_FILE, 'utf8'))
|
|
154
|
+
return state ?? { version: 2, sessions: {} }
|
|
95
155
|
} catch {
|
|
96
|
-
return { version:
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
}
|