@raidou/pi-notify 0.3.1 → 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 -37
- 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 +12 -10
- package/src/tool.ts +10 -7
- package/src/dashboard/state-tracker.ts +0 -174
- /package/src/{utils.ts → shared/utils.ts} +0 -0
package/README.md
CHANGED
|
@@ -10,11 +10,33 @@ A notification extension for the [pi](https://github.com/earendil-works/pi-codin
|
|
|
10
10
|
pi install npm:@raidou/pi-notify
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Or, for local development
|
|
13
|
+
Or, for local development
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd path/to/pi-notify
|
|
17
|
+
pi install .
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configuration
|
|
21
|
+
|
|
22
|
+
All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everything is optional.
|
|
14
23
|
|
|
15
24
|
```jsonc
|
|
16
25
|
{
|
|
17
|
-
"
|
|
26
|
+
"piNotify": {
|
|
27
|
+
"enabled": true, // master on/off switch (default: true)
|
|
28
|
+
"notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
|
|
29
|
+
"tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
|
|
30
|
+
"finished": true, // enable/disable "Idle" notification
|
|
31
|
+
"events": {
|
|
32
|
+
"permissions:ui_prompt": "Permission prompt", // custom event channel -> notification message
|
|
33
|
+
"my:custom:event": "Custom event triggered", // add your own custom events
|
|
34
|
+
"other:event": false, // set to false to disable a specific event
|
|
35
|
+
},
|
|
36
|
+
"finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
|
|
37
|
+
"onlyNotifyWhenUnfocused": true, // only notify when user has been inactive
|
|
38
|
+
"unfocusedActivityThresholdSecs": 30, // seconds of inactivity before considering user "unfocused"
|
|
39
|
+
},
|
|
18
40
|
}
|
|
19
41
|
```
|
|
20
42
|
|
|
@@ -44,42 +66,6 @@ function endBackgroundJob(jobId: string): void {
|
|
|
44
66
|
|
|
45
67
|
Events are automatically cleaned up on `session_shutdown`.
|
|
46
68
|
|
|
47
|
-
## Configuration
|
|
48
|
-
|
|
49
|
-
All options live under the `piNotify` key in `~/.pi/agent/settings.json`. Everything is optional.
|
|
50
|
-
|
|
51
|
-
```jsonc
|
|
52
|
-
{
|
|
53
|
-
"piNotify": {
|
|
54
|
-
"enabled": true, // master on/off switch (default: true)
|
|
55
|
-
"notifyTools": ["ask_user", "ask_user_question"], // tools that trigger "Tool call" notifications
|
|
56
|
-
"tmuxSymbol": "🔔", // symbol appended to tmux window title (empty string to disable)
|
|
57
|
-
"finished": true, // enable/disable "Idle" notification
|
|
58
|
-
"events": {
|
|
59
|
-
"permissions:ui_prompt": "Permission prompt", // custom event channel -> notification message
|
|
60
|
-
"my:custom:event": "Custom event triggered", // add your own custom events
|
|
61
|
-
},
|
|
62
|
-
"finishedThrottleSecs": 0, // 0 = always notify; >0 = skip finished toasts for runs shorter than N seconds
|
|
63
|
-
"onlyNotifyWhenUnfocused": true, // only notify when user has been inactive
|
|
64
|
-
"unfocusedActivityThresholdSecs": 30, // seconds of inactivity before considering user "unfocused"
|
|
65
|
-
},
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Disabling specific events
|
|
70
|
-
|
|
71
|
-
To disable a specific event, set its message to an empty string:
|
|
72
|
-
|
|
73
|
-
```jsonc
|
|
74
|
-
{
|
|
75
|
-
"piNotify": {
|
|
76
|
-
"events": {
|
|
77
|
-
"permissions:ui_prompt": "", // disable permission notifications
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
-
|
|
83
69
|
## Testing
|
|
84
70
|
|
|
85
71
|
Run `/notify-test` inside pi to fire a test notification.
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidou/pi-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Desktop notification extension for the pi coding agent.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"pi-package",
|
|
7
6
|
"pi",
|
|
7
|
+
"pi-package",
|
|
8
8
|
"pi-extension",
|
|
9
9
|
"pi-coding-agent",
|
|
10
10
|
"notifications",
|
|
@@ -28,13 +28,16 @@
|
|
|
28
28
|
},
|
|
29
29
|
"main": "./src/index.ts",
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "pnpm run test:types && pnpm run test:lint",
|
|
31
|
+
"test": "pnpm run test:types && pnpm run test:lint && pnpm run test:unit",
|
|
32
32
|
"test:types": "tsc --noEmit",
|
|
33
|
-
"test:lint": "eslint --fix ."
|
|
33
|
+
"test:lint": "eslint --fix .",
|
|
34
|
+
"test:unit": "vitest run"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
37
|
"@earendil-works/pi-tui": "0.80.7",
|
|
37
|
-
"
|
|
38
|
+
"emittery": "^2.0.0",
|
|
39
|
+
"node-notifier": "^10.0.1",
|
|
40
|
+
"proper-lockfile": "^4.1.2"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|
|
40
43
|
"@earendil-works/pi-coding-agent": ">=0.79.0"
|
|
@@ -42,8 +45,10 @@
|
|
|
42
45
|
"devDependencies": {
|
|
43
46
|
"@raidou/eslint-config-base": "^4.4.3",
|
|
44
47
|
"@types/node": "^22.0.0",
|
|
48
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
45
49
|
"eslint": "^10.7.0",
|
|
46
50
|
"prettier": "^3.9.5",
|
|
47
|
-
"typescript": "^5.6.0"
|
|
51
|
+
"typescript": "^5.6.0",
|
|
52
|
+
"vitest": "^4.1.11"
|
|
48
53
|
}
|
|
49
54
|
}
|
package/src/config.ts
CHANGED
|
@@ -3,8 +3,9 @@ import { join } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
5
5
|
|
|
6
|
+
// `false` disables the event
|
|
6
7
|
interface NotifyEventsConfig {
|
|
7
|
-
readonly [channel: string]: string
|
|
8
|
+
readonly [channel: string]: string | false
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
interface NotifyConfig {
|
package/src/dashboard/command.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { Registrar } from '../shared/registrar.js'
|
|
3
2
|
import { readSessions } from './state-store.js'
|
|
4
3
|
import { createDashboard } from './ui.js'
|
|
5
4
|
|
|
6
|
-
export class DashboardCommand {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
register(): void {
|
|
5
|
+
export class DashboardCommand extends Registrar {
|
|
6
|
+
protected override setup(): void {
|
|
10
7
|
this.pi.registerCommand('notify-dashboard', {
|
|
11
8
|
description: 'Show all pi sessions notify dashboard',
|
|
12
9
|
handler: async (_args, ctx) => {
|
|
13
|
-
const initialSessions = readSessions()
|
|
10
|
+
const initialSessions = await readSessions()
|
|
14
11
|
|
|
15
12
|
await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
|
|
13
|
+
let closed = false
|
|
16
14
|
const dashboard = createDashboard({
|
|
17
15
|
tui,
|
|
18
16
|
theme,
|
|
19
17
|
initialSessions,
|
|
20
18
|
onRefresh: async () => readSessions(),
|
|
21
19
|
onClose: () => {
|
|
20
|
+
if (closed) return
|
|
21
|
+
closed = true
|
|
22
|
+
dashboard.dispose()
|
|
22
23
|
done(undefined)
|
|
23
24
|
},
|
|
24
25
|
})
|
|
@@ -27,6 +28,7 @@ export class DashboardCommand {
|
|
|
27
28
|
render: dashboard.render.bind(dashboard),
|
|
28
29
|
handleInput: dashboard.handleInput.bind(dashboard),
|
|
29
30
|
invalidate: dashboard.invalidate.bind(dashboard),
|
|
31
|
+
dispose: dashboard.dispose.bind(dashboard),
|
|
30
32
|
}
|
|
31
33
|
})
|
|
32
34
|
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { unlinkSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
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'
|
|
7
|
+
|
|
8
|
+
import type { StateTracker } from '../state-tracker.js'
|
|
9
|
+
import { SessionStore } from './session-store.js'
|
|
10
|
+
import { readState, updateState } from './state-store.js'
|
|
11
|
+
|
|
12
|
+
type EventsListener = (payload: unknown) => void
|
|
13
|
+
|
|
14
|
+
interface FakeStateTracker {
|
|
15
|
+
events: {
|
|
16
|
+
on(event: string, listener: EventsListener): () => void
|
|
17
|
+
emit(event: string, data?: unknown): void
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FakeSessionManager {
|
|
22
|
+
getSessionId(): string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FakePiContext {
|
|
26
|
+
cwd: string
|
|
27
|
+
sessionManager: FakeSessionManager
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FakePi {
|
|
31
|
+
listeners: Map<string, Set<(...args: unknown[]) => void>>
|
|
32
|
+
eventListeners: Map<string, Set<EventsListener>>
|
|
33
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
34
|
+
events: { on(event: string, listener: EventsListener): () => void }
|
|
35
|
+
emit(event: string, ...args: unknown[]): void
|
|
36
|
+
emitEvent(event: string, payload: unknown): void
|
|
37
|
+
emitSessionStart(ctx: FakePiContext): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeFakePi(): FakePi {
|
|
41
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
|
|
42
|
+
const eventListeners = new Map<string, Set<EventsListener>>()
|
|
43
|
+
const pi: FakePi = {
|
|
44
|
+
listeners,
|
|
45
|
+
eventListeners,
|
|
46
|
+
on(event, listener) {
|
|
47
|
+
let set = listeners.get(event)
|
|
48
|
+
if (!set) {
|
|
49
|
+
set = new Set()
|
|
50
|
+
listeners.set(event, set)
|
|
51
|
+
}
|
|
52
|
+
set.add(listener)
|
|
53
|
+
},
|
|
54
|
+
events: {
|
|
55
|
+
on(event, listener) {
|
|
56
|
+
let set = eventListeners.get(event)
|
|
57
|
+
if (!set) {
|
|
58
|
+
set = new Set()
|
|
59
|
+
eventListeners.set(event, set)
|
|
60
|
+
}
|
|
61
|
+
set.add(listener)
|
|
62
|
+
return () => {
|
|
63
|
+
set.delete(listener)
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
emit(event, ...args) {
|
|
68
|
+
const set = listeners.get(event)
|
|
69
|
+
if (!set) return
|
|
70
|
+
for (const listener of [...set]) listener(...args)
|
|
71
|
+
},
|
|
72
|
+
emitEvent(event, payload) {
|
|
73
|
+
const set = eventListeners.get(event)
|
|
74
|
+
if (!set) return
|
|
75
|
+
for (const listener of [...set]) listener(payload)
|
|
76
|
+
},
|
|
77
|
+
emitSessionStart(ctx) {
|
|
78
|
+
const set = listeners.get('session_start')
|
|
79
|
+
if (!set) return
|
|
80
|
+
for (const listener of [...set]) listener(undefined, ctx)
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
return pi
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function makeFakeStateTracker(): FakeStateTracker {
|
|
87
|
+
const listeners = new Map<string, Set<EventsListener>>()
|
|
88
|
+
return {
|
|
89
|
+
events: {
|
|
90
|
+
on(event, listener) {
|
|
91
|
+
let set = listeners.get(event)
|
|
92
|
+
if (!set) {
|
|
93
|
+
set = new Set()
|
|
94
|
+
listeners.set(event, set)
|
|
95
|
+
}
|
|
96
|
+
set.add(listener)
|
|
97
|
+
return () => {
|
|
98
|
+
set.delete(listener)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
emit(event, data?: unknown) {
|
|
102
|
+
const set = listeners.get(event)
|
|
103
|
+
if (!set) return
|
|
104
|
+
for (const listener of [...set]) listener({ data })
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const SESSION_ID = 'test-session-123'
|
|
111
|
+
const META = {
|
|
112
|
+
cwd: '/test/project',
|
|
113
|
+
projectName: 'test-project',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('SessionStore', () => {
|
|
117
|
+
const testLockFile = join(getAgentDir(), 'pi-notify-test', 'state.json.lock')
|
|
118
|
+
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(testLockFile)
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
await updateState(() => ({ version: 1, sessions: {} }))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(testLockFile)
|
|
131
|
+
} catch {
|
|
132
|
+
// ignore
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('creates instance and registers event listeners', () => {
|
|
137
|
+
const pi = makeFakePi()
|
|
138
|
+
const stateTracker = makeFakeStateTracker()
|
|
139
|
+
const emitSpy = vi.spyOn(stateTracker.events, 'on')
|
|
140
|
+
const piOnSpy = vi.spyOn(pi, 'on')
|
|
141
|
+
|
|
142
|
+
const store = new SessionStore(
|
|
143
|
+
pi as unknown as ExtensionAPI,
|
|
144
|
+
stateTracker as unknown as StateTracker,
|
|
145
|
+
)
|
|
146
|
+
store.register(vi.fn())
|
|
147
|
+
|
|
148
|
+
expect(emitSpy).toHaveBeenCalledTimes(2)
|
|
149
|
+
expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
|
|
150
|
+
expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
|
|
151
|
+
expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('handles session_start event without errors', () => {
|
|
155
|
+
const pi = makeFakePi()
|
|
156
|
+
const stateTracker = makeFakeStateTracker()
|
|
157
|
+
|
|
158
|
+
const store = new SessionStore(
|
|
159
|
+
pi as unknown as ExtensionAPI,
|
|
160
|
+
stateTracker as unknown as StateTracker,
|
|
161
|
+
)
|
|
162
|
+
store.register(vi.fn())
|
|
163
|
+
|
|
164
|
+
expect(() => {
|
|
165
|
+
pi.emitSessionStart({
|
|
166
|
+
cwd: META.cwd,
|
|
167
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
168
|
+
})
|
|
169
|
+
}).not.toThrow()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('handles running event without errors', () => {
|
|
173
|
+
const pi = makeFakePi()
|
|
174
|
+
const stateTracker = makeFakeStateTracker()
|
|
175
|
+
|
|
176
|
+
const store = new SessionStore(
|
|
177
|
+
pi as unknown as ExtensionAPI,
|
|
178
|
+
stateTracker as unknown as StateTracker,
|
|
179
|
+
)
|
|
180
|
+
store.register(vi.fn())
|
|
181
|
+
|
|
182
|
+
pi.emitSessionStart({
|
|
183
|
+
cwd: META.cwd,
|
|
184
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(() => {
|
|
188
|
+
stateTracker.events.emit('running')
|
|
189
|
+
}).not.toThrow()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('handles idle event without errors', () => {
|
|
193
|
+
const pi = makeFakePi()
|
|
194
|
+
const stateTracker = makeFakeStateTracker()
|
|
195
|
+
|
|
196
|
+
const store = new SessionStore(
|
|
197
|
+
pi as unknown as ExtensionAPI,
|
|
198
|
+
stateTracker as unknown as StateTracker,
|
|
199
|
+
)
|
|
200
|
+
store.register(vi.fn())
|
|
201
|
+
|
|
202
|
+
pi.emitSessionStart({
|
|
203
|
+
cwd: META.cwd,
|
|
204
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
expect(() => {
|
|
208
|
+
stateTracker.events.emit('idle')
|
|
209
|
+
}).not.toThrow()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('ignores running event when session not started', () => {
|
|
213
|
+
const pi = makeFakePi()
|
|
214
|
+
const stateTracker = makeFakeStateTracker()
|
|
215
|
+
|
|
216
|
+
const store = new SessionStore(
|
|
217
|
+
pi as unknown as ExtensionAPI,
|
|
218
|
+
stateTracker as unknown as StateTracker,
|
|
219
|
+
)
|
|
220
|
+
store.register(vi.fn())
|
|
221
|
+
|
|
222
|
+
expect(() => {
|
|
223
|
+
stateTracker.events.emit('running')
|
|
224
|
+
}).not.toThrow()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('ignores idle event when session not started', () => {
|
|
228
|
+
const pi = makeFakePi()
|
|
229
|
+
const stateTracker = makeFakeStateTracker()
|
|
230
|
+
|
|
231
|
+
const store = new SessionStore(
|
|
232
|
+
pi as unknown as ExtensionAPI,
|
|
233
|
+
stateTracker as unknown as StateTracker,
|
|
234
|
+
)
|
|
235
|
+
store.register(vi.fn())
|
|
236
|
+
|
|
237
|
+
expect(() => {
|
|
238
|
+
stateTracker.events.emit('idle')
|
|
239
|
+
}).not.toThrow()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('writes running and idle states to state.json', async () => {
|
|
243
|
+
const pi = makeFakePi()
|
|
244
|
+
const stateTracker = makeFakeStateTracker()
|
|
245
|
+
|
|
246
|
+
const store = new SessionStore(
|
|
247
|
+
pi as unknown as ExtensionAPI,
|
|
248
|
+
stateTracker as unknown as StateTracker,
|
|
249
|
+
)
|
|
250
|
+
store.register(() => {})
|
|
251
|
+
|
|
252
|
+
pi.emitSessionStart({
|
|
253
|
+
cwd: META.cwd,
|
|
254
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
255
|
+
})
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
257
|
+
|
|
258
|
+
stateTracker.events.emit('running')
|
|
259
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
260
|
+
|
|
261
|
+
let record = readState().sessions[String(process.pid)]
|
|
262
|
+
expect(record?.state).toBe('running')
|
|
263
|
+
|
|
264
|
+
stateTracker.events.emit('idle')
|
|
265
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
266
|
+
|
|
267
|
+
record = readState().sessions[String(process.pid)]
|
|
268
|
+
expect(record?.state).toBe('idle')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('stops without errors after session started', () => {
|
|
272
|
+
const pi = makeFakePi()
|
|
273
|
+
const stateTracker = makeFakeStateTracker()
|
|
274
|
+
|
|
275
|
+
const store = new SessionStore(
|
|
276
|
+
pi as unknown as ExtensionAPI,
|
|
277
|
+
stateTracker as unknown as StateTracker,
|
|
278
|
+
)
|
|
279
|
+
store.register(vi.fn())
|
|
280
|
+
|
|
281
|
+
pi.emitSessionStart({
|
|
282
|
+
cwd: META.cwd,
|
|
283
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(() => {
|
|
287
|
+
store.stop()
|
|
288
|
+
}).not.toThrow()
|
|
289
|
+
})
|
|
290
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
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 } from './state-store.js'
|
|
9
|
+
import { updateState } from './state-store.js'
|
|
10
|
+
|
|
11
|
+
export interface SessionUpdate {
|
|
12
|
+
state: 'running' | 'idle'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SessionStore extends Registrar {
|
|
16
|
+
private readonly stateTracker: StateTracker
|
|
17
|
+
private sessionId: string | undefined = undefined
|
|
18
|
+
private meta: { pid: number; cwd: string; projectName: string } | undefined =
|
|
19
|
+
undefined
|
|
20
|
+
|
|
21
|
+
constructor(pi: ExtensionAPI, stateTracker: StateTracker) {
|
|
22
|
+
super(pi)
|
|
23
|
+
this.stateTracker = stateTracker
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private async saveSession(updates: SessionUpdate): Promise<void> {
|
|
27
|
+
if (this.sessionId === undefined || this.meta === undefined) return
|
|
28
|
+
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
const meta = this.meta
|
|
31
|
+
const sessionId = this.sessionId
|
|
32
|
+
const id = String(meta.pid)
|
|
33
|
+
|
|
34
|
+
await updateState((state) => {
|
|
35
|
+
const existing = state.sessions[id]
|
|
36
|
+
const stateChanged = updates.state !== existing?.state
|
|
37
|
+
|
|
38
|
+
const record: SessionRecord = {
|
|
39
|
+
pid: meta.pid,
|
|
40
|
+
sessionId,
|
|
41
|
+
cwd: meta.cwd,
|
|
42
|
+
projectName: meta.projectName,
|
|
43
|
+
startedAt: existing?.startedAt ?? now,
|
|
44
|
+
state: updates.state,
|
|
45
|
+
stateChangedAt: stateChanged ? now : existing.stateChangedAt,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
sessions: { ...state.sessions, [id]: record },
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
protected override setup(): void {
|
|
56
|
+
this.pi.on('session_start', (_event, ctx) => {
|
|
57
|
+
let normalizedCwd = ctx.cwd
|
|
58
|
+
try {
|
|
59
|
+
normalizedCwd = realpathSync(ctx.cwd)
|
|
60
|
+
} catch {
|
|
61
|
+
// Use original path if realpathSync fails
|
|
62
|
+
}
|
|
63
|
+
const meta = {
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
cwd: normalizedCwd,
|
|
66
|
+
projectName: basename(normalizedCwd),
|
|
67
|
+
}
|
|
68
|
+
const sessionId = ctx.sessionManager.getSessionId()
|
|
69
|
+
this.sessionId = sessionId
|
|
70
|
+
this.meta = meta
|
|
71
|
+
void this.saveSession({ state: 'idle' })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
this.unsubscribes.push(
|
|
75
|
+
this.stateTracker.events.on('running', async () => {
|
|
76
|
+
await this.saveSession({ state: 'running' })
|
|
77
|
+
}),
|
|
78
|
+
this.stateTracker.events.on('idle', async () => {
|
|
79
|
+
await this.saveSession({ state: 'idle' })
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|