@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
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,43 +66,21 @@ function endBackgroundJob(jobId: string): void {
|
|
|
44
66
|
|
|
45
67
|
Events are automatically cleaned up on `session_shutdown`.
|
|
46
68
|
|
|
47
|
-
##
|
|
69
|
+
## Commands
|
|
48
70
|
|
|
49
|
-
|
|
71
|
+
### `/notify-dashboard`
|
|
50
72
|
|
|
51
|
-
|
|
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
|
-
```
|
|
73
|
+
Open a full-screen TUI dashboard listing all pi sessions, with summary counts (total, running, idle) and an auto-refresh every second.
|
|
68
74
|
|
|
69
|
-
|
|
75
|
+
Columns: `SESSION_ID` (last 6 chars), `PID`, `STATE` (running/idle, color-coded), `PROJECT`, `UPTIME`.
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
Keybindings:
|
|
72
78
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
"events": {
|
|
77
|
-
"permissions:ui_prompt": "", // disable permission notifications
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
```
|
|
79
|
+
- `↑` / `↓` or `j` / `k` — scroll
|
|
80
|
+
- `r` — refresh
|
|
81
|
+
- `q` or `esc` — close
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
### `/notify-test`
|
|
84
84
|
|
|
85
85
|
Run `/notify-test` inside pi to fire a test notification.
|
|
86
86
|
Pass a string argument (e.g., `/notify-test hello`) to override the body.
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raidou/pi-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,22 +28,29 @@
|
|
|
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
|
+
"lodash-es": "^4.18.1",
|
|
40
|
+
"node-notifier": "^10.0.1",
|
|
41
|
+
"proper-lockfile": "^4.1.2"
|
|
38
42
|
},
|
|
39
43
|
"peerDependencies": {
|
|
40
44
|
"@earendil-works/pi-coding-agent": ">=0.79.0"
|
|
41
45
|
},
|
|
42
46
|
"devDependencies": {
|
|
43
47
|
"@raidou/eslint-config-base": "^4.4.3",
|
|
48
|
+
"@types/lodash-es": "^4.17.12",
|
|
44
49
|
"@types/node": "^22.0.0",
|
|
50
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
45
51
|
"eslint": "^10.7.0",
|
|
46
52
|
"prettier": "^3.9.5",
|
|
47
|
-
"typescript": "^5.6.0"
|
|
53
|
+
"typescript": "^5.6.0",
|
|
54
|
+
"vitest": "^4.1.11"
|
|
48
55
|
}
|
|
49
56
|
}
|
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 {
|
|
@@ -12,7 +13,6 @@ interface NotifyConfig {
|
|
|
12
13
|
readonly notifyTools?: readonly string[]
|
|
13
14
|
readonly events?: NotifyEventsConfig
|
|
14
15
|
readonly finished?: boolean
|
|
15
|
-
readonly finishedThrottleSecs?: number
|
|
16
16
|
readonly onlyNotifyWhenUnfocused?: boolean
|
|
17
17
|
readonly unfocusedActivityThresholdSecs?: number
|
|
18
18
|
readonly tmuxSymbol?: string
|
|
@@ -23,7 +23,6 @@ export interface ResolvedNotifyConfig {
|
|
|
23
23
|
readonly notifyTools: ReadonlySet<string>
|
|
24
24
|
readonly events: NotifyEventsConfig
|
|
25
25
|
readonly finished: boolean
|
|
26
|
-
readonly finishedThrottleMs: number
|
|
27
26
|
readonly onlyNotifyWhenUnfocused: boolean
|
|
28
27
|
readonly unfocusedActivityThresholdMs: number
|
|
29
28
|
readonly tmuxSymbol: string
|
|
@@ -66,7 +65,6 @@ export function loadConfig(): ResolvedNotifyConfig {
|
|
|
66
65
|
notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
|
|
67
66
|
events,
|
|
68
67
|
finished: cfg.finished ?? true,
|
|
69
|
-
finishedThrottleMs: Math.max(0, (cfg.finishedThrottleSecs ?? 0) * 1000),
|
|
70
68
|
onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
|
|
71
69
|
unfocusedActivityThresholdMs: Math.max(
|
|
72
70
|
0,
|
package/src/dashboard/command.ts
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { Registrar } from '../shared/registrar.js'
|
|
3
2
|
import { readSessions } from './state-store.js'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
export class DashboardCommand {
|
|
7
|
-
constructor(private readonly pi: ExtensionAPI) {}
|
|
3
|
+
import { Dashboard } from './ui/dashboard.js'
|
|
8
4
|
|
|
9
|
-
|
|
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) => {
|
|
16
|
-
|
|
13
|
+
let closed = false
|
|
14
|
+
const dashboard = new Dashboard({
|
|
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
|
})
|
|
25
26
|
|
|
26
|
-
return
|
|
27
|
-
render: dashboard.render.bind(dashboard),
|
|
28
|
-
handleInput: dashboard.handleInput.bind(dashboard),
|
|
29
|
-
invalidate: dashboard.invalidate.bind(dashboard),
|
|
30
|
-
}
|
|
27
|
+
return dashboard
|
|
31
28
|
})
|
|
32
29
|
|
|
33
30
|
return undefined
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { rmSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
it,
|
|
12
|
+
vi,
|
|
13
|
+
} from 'vitest'
|
|
14
|
+
|
|
15
|
+
vi.mock('./consts.js', async () => {
|
|
16
|
+
const { mkdtempSync } = await import('node:fs')
|
|
17
|
+
const path = await import('node:path')
|
|
18
|
+
const { tmpdir } = await import('node:os')
|
|
19
|
+
|
|
20
|
+
const stateDir = mkdtempSync(path.join(tmpdir(), 'pi-notify-test-'))
|
|
21
|
+
return {
|
|
22
|
+
STATE_FILE: path.join(stateDir, 'state.json'),
|
|
23
|
+
STATE_TMP_FILE: path.join(stateDir, 'state.json.tmp'),
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
import type { ResolvedNotifyConfig } from '../config.js'
|
|
28
|
+
import type { JobTracker } from '../jobs.js'
|
|
29
|
+
import { StateTracker } from '../state-tracker.js'
|
|
30
|
+
import { STATE_FILE } from './consts.js'
|
|
31
|
+
import { SessionStore } from './session-store.js'
|
|
32
|
+
import { readState, updateState } from './state-store.js'
|
|
33
|
+
|
|
34
|
+
type EventsListener = (payload: unknown) => void
|
|
35
|
+
|
|
36
|
+
interface FakeStateTracker {
|
|
37
|
+
events: {
|
|
38
|
+
on(event: string, listener: EventsListener): () => void
|
|
39
|
+
emit(event: string, data?: unknown): void
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface FakeSessionManager {
|
|
44
|
+
getSessionId(): string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface FakePiContext {
|
|
48
|
+
cwd: string
|
|
49
|
+
sessionManager: FakeSessionManager
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface FakePi {
|
|
53
|
+
listeners: Map<string, Set<(...args: unknown[]) => void>>
|
|
54
|
+
eventListeners: Map<string, Set<EventsListener>>
|
|
55
|
+
on(event: string, listener: (...args: unknown[]) => void): void
|
|
56
|
+
events: { on(event: string, listener: EventsListener): () => void }
|
|
57
|
+
emit(event: string, ...args: unknown[]): void
|
|
58
|
+
emitEvent(event: string, payload: unknown): void
|
|
59
|
+
emitSessionStart(ctx: FakePiContext): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFakePi(): FakePi {
|
|
63
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>()
|
|
64
|
+
const eventListeners = new Map<string, Set<EventsListener>>()
|
|
65
|
+
const pi: FakePi = {
|
|
66
|
+
listeners,
|
|
67
|
+
eventListeners,
|
|
68
|
+
on(event, listener) {
|
|
69
|
+
let set = listeners.get(event)
|
|
70
|
+
if (!set) {
|
|
71
|
+
set = new Set()
|
|
72
|
+
listeners.set(event, set)
|
|
73
|
+
}
|
|
74
|
+
set.add(listener)
|
|
75
|
+
},
|
|
76
|
+
events: {
|
|
77
|
+
on(event, listener) {
|
|
78
|
+
let set = eventListeners.get(event)
|
|
79
|
+
if (!set) {
|
|
80
|
+
set = new Set()
|
|
81
|
+
eventListeners.set(event, set)
|
|
82
|
+
}
|
|
83
|
+
set.add(listener)
|
|
84
|
+
return () => {
|
|
85
|
+
set.delete(listener)
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
emit(event, ...args) {
|
|
90
|
+
const set = listeners.get(event)
|
|
91
|
+
if (!set) return
|
|
92
|
+
for (const listener of [...set]) listener(...args)
|
|
93
|
+
},
|
|
94
|
+
emitEvent(event, payload) {
|
|
95
|
+
const set = eventListeners.get(event)
|
|
96
|
+
if (!set) return
|
|
97
|
+
for (const listener of [...set]) listener(payload)
|
|
98
|
+
},
|
|
99
|
+
emitSessionStart(ctx) {
|
|
100
|
+
const set = listeners.get('session_start')
|
|
101
|
+
if (!set) return
|
|
102
|
+
for (const listener of [...set]) listener(undefined, ctx)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
return pi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeFakeStateTracker(): FakeStateTracker {
|
|
109
|
+
const listeners = new Map<string, Set<EventsListener>>()
|
|
110
|
+
return {
|
|
111
|
+
events: {
|
|
112
|
+
on(event, listener) {
|
|
113
|
+
let set = listeners.get(event)
|
|
114
|
+
if (!set) {
|
|
115
|
+
set = new Set()
|
|
116
|
+
listeners.set(event, set)
|
|
117
|
+
}
|
|
118
|
+
set.add(listener)
|
|
119
|
+
return () => {
|
|
120
|
+
set.delete(listener)
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
emit(event, data?: unknown) {
|
|
124
|
+
const set = listeners.get(event)
|
|
125
|
+
if (!set) return
|
|
126
|
+
for (const listener of [...set]) listener({ data })
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SESSION_ID = 'test-session-123'
|
|
133
|
+
const META = {
|
|
134
|
+
cwd: '/test/project',
|
|
135
|
+
projectName: 'test-project',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
afterAll(() => {
|
|
139
|
+
rmSync(dirname(STATE_FILE), { recursive: true, force: true })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('SessionStore', () => {
|
|
143
|
+
const testLockFile = `${STATE_FILE}.lock`
|
|
144
|
+
|
|
145
|
+
beforeEach(async () => {
|
|
146
|
+
try {
|
|
147
|
+
unlinkSync(testLockFile)
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore
|
|
150
|
+
}
|
|
151
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
try {
|
|
156
|
+
unlinkSync(testLockFile)
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('creates instance and registers event listeners', () => {
|
|
163
|
+
const pi = makeFakePi()
|
|
164
|
+
const stateTracker = makeFakeStateTracker()
|
|
165
|
+
const emitSpy = vi.spyOn(stateTracker.events, 'on')
|
|
166
|
+
const piOnSpy = vi.spyOn(pi, 'on')
|
|
167
|
+
|
|
168
|
+
const store = new SessionStore(
|
|
169
|
+
pi as unknown as ExtensionAPI,
|
|
170
|
+
stateTracker as unknown as StateTracker,
|
|
171
|
+
)
|
|
172
|
+
store.register(vi.fn())
|
|
173
|
+
|
|
174
|
+
expect(emitSpy).toHaveBeenCalledTimes(4)
|
|
175
|
+
expect(emitSpy).toHaveBeenCalledWith('running', expect.any(Function))
|
|
176
|
+
expect(emitSpy).toHaveBeenCalledWith('idle', expect.any(Function))
|
|
177
|
+
expect(emitSpy).toHaveBeenCalledWith('tool', expect.any(Function))
|
|
178
|
+
expect(emitSpy).toHaveBeenCalledWith('event', expect.any(Function))
|
|
179
|
+
expect(piOnSpy).toHaveBeenCalledWith('session_start', expect.any(Function))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('handles session_start event without errors', () => {
|
|
183
|
+
const pi = makeFakePi()
|
|
184
|
+
const stateTracker = makeFakeStateTracker()
|
|
185
|
+
|
|
186
|
+
const store = new SessionStore(
|
|
187
|
+
pi as unknown as ExtensionAPI,
|
|
188
|
+
stateTracker as unknown as StateTracker,
|
|
189
|
+
)
|
|
190
|
+
store.register(vi.fn())
|
|
191
|
+
|
|
192
|
+
expect(() => {
|
|
193
|
+
pi.emitSessionStart({
|
|
194
|
+
cwd: META.cwd,
|
|
195
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
196
|
+
})
|
|
197
|
+
}).not.toThrow()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('handles running event without errors', () => {
|
|
201
|
+
const pi = makeFakePi()
|
|
202
|
+
const stateTracker = makeFakeStateTracker()
|
|
203
|
+
|
|
204
|
+
const store = new SessionStore(
|
|
205
|
+
pi as unknown as ExtensionAPI,
|
|
206
|
+
stateTracker as unknown as StateTracker,
|
|
207
|
+
)
|
|
208
|
+
store.register(vi.fn())
|
|
209
|
+
|
|
210
|
+
pi.emitSessionStart({
|
|
211
|
+
cwd: META.cwd,
|
|
212
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect(() => {
|
|
216
|
+
stateTracker.events.emit('running')
|
|
217
|
+
}).not.toThrow()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('handles idle event without errors', () => {
|
|
221
|
+
const pi = makeFakePi()
|
|
222
|
+
const stateTracker = makeFakeStateTracker()
|
|
223
|
+
|
|
224
|
+
const store = new SessionStore(
|
|
225
|
+
pi as unknown as ExtensionAPI,
|
|
226
|
+
stateTracker as unknown as StateTracker,
|
|
227
|
+
)
|
|
228
|
+
store.register(vi.fn())
|
|
229
|
+
|
|
230
|
+
pi.emitSessionStart({
|
|
231
|
+
cwd: META.cwd,
|
|
232
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(() => {
|
|
236
|
+
stateTracker.events.emit('idle')
|
|
237
|
+
}).not.toThrow()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('ignores running event when session not started', () => {
|
|
241
|
+
const pi = makeFakePi()
|
|
242
|
+
const stateTracker = makeFakeStateTracker()
|
|
243
|
+
|
|
244
|
+
const store = new SessionStore(
|
|
245
|
+
pi as unknown as ExtensionAPI,
|
|
246
|
+
stateTracker as unknown as StateTracker,
|
|
247
|
+
)
|
|
248
|
+
store.register(vi.fn())
|
|
249
|
+
|
|
250
|
+
expect(() => {
|
|
251
|
+
stateTracker.events.emit('running')
|
|
252
|
+
}).not.toThrow()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('ignores idle event when session not started', () => {
|
|
256
|
+
const pi = makeFakePi()
|
|
257
|
+
const stateTracker = makeFakeStateTracker()
|
|
258
|
+
|
|
259
|
+
const store = new SessionStore(
|
|
260
|
+
pi as unknown as ExtensionAPI,
|
|
261
|
+
stateTracker as unknown as StateTracker,
|
|
262
|
+
)
|
|
263
|
+
store.register(vi.fn())
|
|
264
|
+
|
|
265
|
+
expect(() => {
|
|
266
|
+
stateTracker.events.emit('idle')
|
|
267
|
+
}).not.toThrow()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('writes running and idle states to state.json', async () => {
|
|
271
|
+
const pi = makeFakePi()
|
|
272
|
+
const stateTracker = makeFakeStateTracker()
|
|
273
|
+
|
|
274
|
+
const store = new SessionStore(
|
|
275
|
+
pi as unknown as ExtensionAPI,
|
|
276
|
+
stateTracker as unknown as StateTracker,
|
|
277
|
+
)
|
|
278
|
+
store.register(() => {})
|
|
279
|
+
|
|
280
|
+
pi.emitSessionStart({
|
|
281
|
+
cwd: META.cwd,
|
|
282
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
283
|
+
})
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
285
|
+
|
|
286
|
+
stateTracker.events.emit('running')
|
|
287
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
288
|
+
|
|
289
|
+
let record = readState().sessions[String(process.pid)]
|
|
290
|
+
expect(record?.state).toBe('running')
|
|
291
|
+
|
|
292
|
+
stateTracker.events.emit('idle')
|
|
293
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
294
|
+
|
|
295
|
+
record = readState().sessions[String(process.pid)]
|
|
296
|
+
expect(record?.state).toBe('idle')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('stops without errors after session started', () => {
|
|
300
|
+
const pi = makeFakePi()
|
|
301
|
+
const stateTracker = makeFakeStateTracker()
|
|
302
|
+
|
|
303
|
+
const store = new SessionStore(
|
|
304
|
+
pi as unknown as ExtensionAPI,
|
|
305
|
+
stateTracker as unknown as StateTracker,
|
|
306
|
+
)
|
|
307
|
+
store.register(vi.fn())
|
|
308
|
+
|
|
309
|
+
pi.emitSessionStart({
|
|
310
|
+
cwd: META.cwd,
|
|
311
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
expect(() => {
|
|
315
|
+
store.stop()
|
|
316
|
+
}).not.toThrow()
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('clears runningSince on idle transition without accumulating duration', async () => {
|
|
320
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
321
|
+
const now = Date.now()
|
|
322
|
+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now)
|
|
323
|
+
const pi = makeFakePi()
|
|
324
|
+
const stateTracker = makeFakeStateTracker()
|
|
325
|
+
const store = new SessionStore(
|
|
326
|
+
pi as unknown as ExtensionAPI,
|
|
327
|
+
stateTracker as unknown as StateTracker,
|
|
328
|
+
)
|
|
329
|
+
store.register(() => {})
|
|
330
|
+
|
|
331
|
+
pi.emitSessionStart({
|
|
332
|
+
cwd: META.cwd,
|
|
333
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
334
|
+
})
|
|
335
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
336
|
+
|
|
337
|
+
stateTracker.events.emit('running')
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
339
|
+
|
|
340
|
+
nowSpy.mockReturnValue(now + 5000)
|
|
341
|
+
stateTracker.events.emit('idle')
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
343
|
+
|
|
344
|
+
const record = readState().sessions[String(process.pid)]
|
|
345
|
+
expect(record).not.toHaveProperty('runningTime')
|
|
346
|
+
expect(record?.startedRunningAt).toBeUndefined()
|
|
347
|
+
nowSpy.mockRestore()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('does not update state on tool emission outside notifyTools', async () => {
|
|
351
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
352
|
+
const pi = makeFakePi()
|
|
353
|
+
const jobTracker = {
|
|
354
|
+
hasActiveJobs: false,
|
|
355
|
+
onEnd: () => () => {},
|
|
356
|
+
} as unknown as JobTracker
|
|
357
|
+
const tracker = new StateTracker(
|
|
358
|
+
pi as unknown as ExtensionAPI,
|
|
359
|
+
jobTracker,
|
|
360
|
+
{
|
|
361
|
+
notifyTools: new Set(['read']),
|
|
362
|
+
events: {},
|
|
363
|
+
} as unknown as ResolvedNotifyConfig,
|
|
364
|
+
)
|
|
365
|
+
tracker.register(() => {})
|
|
366
|
+
|
|
367
|
+
const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
|
|
368
|
+
store.register(() => {})
|
|
369
|
+
|
|
370
|
+
pi.emitSessionStart({
|
|
371
|
+
cwd: META.cwd,
|
|
372
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
373
|
+
})
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
375
|
+
|
|
376
|
+
expect(readState().sessions[String(process.pid)]?.state).toBe('idle')
|
|
377
|
+
|
|
378
|
+
pi.emit('tool_call', {
|
|
379
|
+
type: 'tool_call',
|
|
380
|
+
toolCallId: 't1',
|
|
381
|
+
toolName: 'grep',
|
|
382
|
+
})
|
|
383
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
384
|
+
|
|
385
|
+
const record = readState().sessions[String(process.pid)]
|
|
386
|
+
expect(record?.state).toBe('idle')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('updates state immediately on event emission', async () => {
|
|
390
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
391
|
+
const pi = makeFakePi()
|
|
392
|
+
const jobTracker = {
|
|
393
|
+
hasActiveJobs: false,
|
|
394
|
+
onEnd: () => () => {},
|
|
395
|
+
} as unknown as JobTracker
|
|
396
|
+
const tracker = new StateTracker(
|
|
397
|
+
pi as unknown as ExtensionAPI,
|
|
398
|
+
jobTracker,
|
|
399
|
+
{
|
|
400
|
+
notifyTools: new Set(['bash']),
|
|
401
|
+
events: { 'permissions:ui_prompt': 'msg' },
|
|
402
|
+
} as unknown as ResolvedNotifyConfig,
|
|
403
|
+
)
|
|
404
|
+
tracker.register(() => {})
|
|
405
|
+
|
|
406
|
+
const store = new SessionStore(pi as unknown as ExtensionAPI, tracker)
|
|
407
|
+
store.register(() => {})
|
|
408
|
+
|
|
409
|
+
pi.emitSessionStart({
|
|
410
|
+
cwd: META.cwd,
|
|
411
|
+
sessionManager: { getSessionId: () => SESSION_ID },
|
|
412
|
+
})
|
|
413
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
414
|
+
|
|
415
|
+
pi.emitEvent('permissions:ui_prompt', {})
|
|
416
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
417
|
+
|
|
418
|
+
const record = readState().sessions[String(process.pid)]
|
|
419
|
+
expect(record?.state).toBe('event:permissions:ui_prompt')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('ignores tool and event emissions before session_start', async () => {
|
|
423
|
+
await updateState(() => ({ version: 2, sessions: {} }))
|
|
424
|
+
const pi = makeFakePi()
|
|
425
|
+
const stateTracker = makeFakeStateTracker()
|
|
426
|
+
const store = new SessionStore(
|
|
427
|
+
pi as unknown as ExtensionAPI,
|
|
428
|
+
stateTracker as unknown as StateTracker,
|
|
429
|
+
)
|
|
430
|
+
store.register(() => {})
|
|
431
|
+
|
|
432
|
+
stateTracker.events.emit('tool', 'grep')
|
|
433
|
+
stateTracker.events.emit('event', 'permissions:ui_prompt')
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
435
|
+
|
|
436
|
+
expect(readState().sessions).toEqual({})
|
|
437
|
+
})
|
|
438
|
+
})
|