@raidou/pi-notify 0.2.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raidou/pi-notify",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Desktop notification extension for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -33,6 +33,7 @@
33
33
  "test:lint": "eslint --fix ."
34
34
  },
35
35
  "dependencies": {
36
+ "@earendil-works/pi-tui": "0.80.7",
36
37
  "node-notifier": "^10.0.1"
37
38
  },
38
39
  "peerDependencies": {
@@ -0,0 +1,37 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
2
+
3
+ import { readSessions } from './state-store.js'
4
+ import { createDashboard } from './ui.js'
5
+
6
+ export class DashboardCommand {
7
+ constructor(private readonly pi: ExtensionAPI) {}
8
+
9
+ register(): void {
10
+ this.pi.registerCommand('notify-dashboard', {
11
+ description: 'Show all pi sessions notify dashboard',
12
+ handler: async (_args, ctx) => {
13
+ const initialSessions = readSessions()
14
+
15
+ await ctx.ui.custom<unknown>((tui, theme, _keybindings, done) => {
16
+ const dashboard = createDashboard({
17
+ tui,
18
+ theme,
19
+ initialSessions,
20
+ onRefresh: async () => readSessions(),
21
+ onClose: () => {
22
+ done(undefined)
23
+ },
24
+ })
25
+
26
+ return {
27
+ render: dashboard.render.bind(dashboard),
28
+ handleInput: dashboard.handleInput.bind(dashboard),
29
+ invalidate: dashboard.invalidate.bind(dashboard),
30
+ }
31
+ })
32
+
33
+ return undefined
34
+ },
35
+ })
36
+ }
37
+ }
@@ -0,0 +1,156 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmdirSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
12
+
13
+ export interface SessionRecord {
14
+ pid: number
15
+ cwd: string
16
+ projectName: string
17
+ startedAt: number
18
+ lastHeartbeatAt: number
19
+ state: 'running' | 'idle'
20
+ stateChangedAt: number
21
+ model: string | undefined
22
+ lastEvent: { type: string; summary: string; at: number } | undefined
23
+ }
24
+
25
+ export interface DashboardState {
26
+ version: 1
27
+ sessions: Record<string, SessionRecord | undefined>
28
+ }
29
+
30
+ const STATE_FILE = join(getAgentDir(), 'pi-notify', 'state.json')
31
+ const LOCK_FILE = `${STATE_FILE}.lock`
32
+ const LOCK_RETRY_DELAY_MS = 50
33
+ const LOCK_MAX_RETRIES = 20
34
+ const STALE_THRESHOLD_MS = 30000
35
+
36
+ function ensureStateDir(): void {
37
+ const dir = dirname(STATE_FILE)
38
+ if (!existsSync(dir)) {
39
+ mkdirSync(dir, { recursive: true })
40
+ }
41
+ }
42
+
43
+ async function acquireLock(retries = LOCK_MAX_RETRIES): Promise<boolean> {
44
+ for (let i = 0; i < retries; i++) {
45
+ try {
46
+ mkdirSync(LOCK_FILE, { recursive: false })
47
+ return true
48
+ } catch (e) {
49
+ const code =
50
+ e instanceof Error && 'code' in e
51
+ ? (e as NodeJS.ErrnoException).code
52
+ : undefined
53
+ if (code === 'EEXIST') {
54
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
55
+ continue
56
+ }
57
+ if (code === 'ENOENT') {
58
+ ensureStateDir()
59
+ await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS))
60
+ continue
61
+ }
62
+ throw e
63
+ }
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()
87
+ if (!existsSync(STATE_FILE)) {
88
+ return { version: 1, sessions: {} }
89
+ }
90
+
91
+ let state: DashboardState
92
+ try {
93
+ const data = readFileSync(STATE_FILE, 'utf8')
94
+ state = JSON.parse(data) as DashboardState
95
+ } catch {
96
+ return { version: 1, sessions: {} }
97
+ }
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
+ }
112
+
113
+ export async function updateState(
114
+ mutator: (state: DashboardState) => DashboardState,
115
+ ): Promise<void> {
116
+ ensureStateDir()
117
+
118
+ const locked = await acquireLock()
119
+ if (!locked) {
120
+ throw new Error('Failed to acquire lock for state update')
121
+ }
122
+
123
+ try {
124
+ const state = readState()
125
+ const newState = mutator(state)
126
+ const tmpFile = `${STATE_FILE}.tmp`
127
+ writeFileSync(tmpFile, JSON.stringify(newState, null, 2), 'utf8')
128
+ renameSync(tmpFile, STATE_FILE)
129
+ } finally {
130
+ releaseLock()
131
+ }
132
+ }
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
+ }
@@ -0,0 +1,174 @@
1
+ import { basename } from 'node:path'
2
+
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
4
+
5
+ import { type SessionRecord, updateState } from './state-store.js'
6
+
7
+ const IDLE_TIMEOUT_MS = 10000
8
+ const HEARTBEAT_INTERVAL_MS = 5000
9
+
10
+ export class StateTracker {
11
+ private registered = false
12
+ private heartbeatTimer: NodeJS.Timeout | null = null
13
+ private idleTimer: NodeJS.Timeout | null = null
14
+ private sessionId: string | undefined = undefined
15
+ private cwd: string | undefined = undefined
16
+ private projectName: string | undefined = undefined
17
+ private model: string | undefined = undefined
18
+
19
+ private get cwdOrCwd(): string {
20
+ return this.cwd ?? process.cwd()
21
+ }
22
+
23
+ private get projectNameOrCwd(): string {
24
+ return this.projectName ?? basename(process.cwd())
25
+ }
26
+
27
+ constructor(private readonly pi: ExtensionAPI) {}
28
+
29
+ private updateSessionRecord(updates: Partial<SessionRecord>): void {
30
+ if (!this.sessionId) return
31
+ const now = Date.now()
32
+ const id = this.sessionId
33
+
34
+ void updateState((state) => {
35
+ const existing = state.sessions[id]
36
+ const stateChanged =
37
+ updates.state !== undefined && updates.state !== existing?.state
38
+
39
+ const record: SessionRecord = {
40
+ pid: process.pid,
41
+ cwd: this.cwdOrCwd,
42
+ projectName: this.projectNameOrCwd,
43
+ lastHeartbeatAt: now,
44
+ startedAt: existing?.startedAt ?? now,
45
+ state: updates.state ?? existing?.state ?? 'running',
46
+ stateChangedAt: stateChanged ? now : (existing?.stateChangedAt ?? now),
47
+ model: this.model ?? existing?.model,
48
+ lastEvent: updates.lastEvent ?? existing?.lastEvent,
49
+ }
50
+
51
+ return {
52
+ ...state,
53
+ sessions: { ...state.sessions, [id]: record },
54
+ }
55
+ })
56
+ }
57
+
58
+ private removeSession(): void {
59
+ if (!this.sessionId) return
60
+ const id = this.sessionId
61
+
62
+ void updateState((state) => {
63
+ const sessions: Record<string, SessionRecord | undefined> = {}
64
+ for (const [key, value] of Object.entries(state.sessions)) {
65
+ if (key !== id) sessions[key] = value
66
+ }
67
+ return { ...state, sessions }
68
+ })
69
+ }
70
+
71
+ private clearIdleTimer(): void {
72
+ if (this.idleTimer) {
73
+ clearTimeout(this.idleTimer)
74
+ this.idleTimer = null
75
+ }
76
+ }
77
+
78
+ private startIdleTimer(): void {
79
+ this.clearIdleTimer()
80
+ this.idleTimer = setTimeout(() => {
81
+ this.clearIdleTimer()
82
+ this.updateSessionRecord({
83
+ state: 'idle',
84
+ lastEvent: { type: 'idle', summary: 'idle', at: Date.now() },
85
+ })
86
+ }, IDLE_TIMEOUT_MS)
87
+ }
88
+
89
+ register(): void {
90
+ if (this.registered) return
91
+ this.registered = true
92
+
93
+ this.pi.on('session_start', (_event, ctx) => {
94
+ this.sessionId = ctx.sessionManager.getSessionId()
95
+ this.cwd = ctx.cwd
96
+ this.projectName = basename(ctx.cwd)
97
+ this.model = ctx.model?.id
98
+
99
+ this.updateSessionRecord({
100
+ lastEvent: {
101
+ type: 'session_start',
102
+ summary: 'started',
103
+ at: Date.now(),
104
+ },
105
+ })
106
+
107
+ this.heartbeatTimer = setInterval(() => {
108
+ if (!this.sessionId) return
109
+ const id = this.sessionId
110
+
111
+ void updateState((state) => {
112
+ const existing = state.sessions[id]
113
+ if (!existing) return state
114
+
115
+ return {
116
+ ...state,
117
+ sessions: {
118
+ ...state.sessions,
119
+ [id]: {
120
+ ...existing,
121
+ lastHeartbeatAt: Date.now(),
122
+ },
123
+ },
124
+ }
125
+ })
126
+ }, HEARTBEAT_INTERVAL_MS)
127
+ })
128
+
129
+ this.pi.on('turn_start', () => {
130
+ this.clearIdleTimer()
131
+ this.updateSessionRecord({
132
+ state: 'running',
133
+ lastEvent: { type: 'turn_start', summary: 'running', at: Date.now() },
134
+ })
135
+ })
136
+
137
+ this.pi.on('agent_start', () => {
138
+ this.clearIdleTimer()
139
+ this.updateSessionRecord({
140
+ state: 'running',
141
+ lastEvent: { type: 'agent_start', summary: 'running', at: Date.now() },
142
+ })
143
+ })
144
+
145
+ this.pi.on('agent_settled', () => {
146
+ this.startIdleTimer()
147
+ })
148
+
149
+ this.pi.on('tool_call', (event) => {
150
+ this.updateSessionRecord({
151
+ state: 'running',
152
+ lastEvent: {
153
+ type: 'tool_call',
154
+ summary: `tool:${event.toolName}`,
155
+ at: Date.now(),
156
+ },
157
+ })
158
+ })
159
+
160
+ this.pi.on('session_shutdown', () => {
161
+ this.stop()
162
+ this.removeSession()
163
+ })
164
+ }
165
+
166
+ stop(): void {
167
+ this.clearIdleTimer()
168
+ if (this.heartbeatTimer) {
169
+ clearInterval(this.heartbeatTimer)
170
+ this.heartbeatTimer = null
171
+ }
172
+ this.registered = false
173
+ }
174
+ }
@@ -0,0 +1,198 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { DynamicBorder } from '@earendil-works/pi-coding-agent'
3
+ import {
4
+ Container,
5
+ Key,
6
+ matchesKey,
7
+ Spacer,
8
+ Text,
9
+ truncateToWidth,
10
+ } from '@earendil-works/pi-tui'
11
+
12
+ import type { SessionRecord } from './state-store.js'
13
+
14
+ const COLUMNS = [
15
+ { name: 'PID', width: 8 },
16
+ { name: 'STATE', width: 8 },
17
+ { name: 'PROJECT', width: 15 },
18
+ { name: 'UPTIME', width: 10 },
19
+ { name: 'MODEL', width: 20 },
20
+ { name: 'LAST EVENT', width: undefined },
21
+ ] as const
22
+
23
+ const [PID_COL, STATE_COL, PROJECT_COL, UPTIME_COL, MODEL_COL] = COLUMNS
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
32
+
33
+ const MAX_ROWS = Math.max(1, (process.stdout.rows || 20) - 6)
34
+
35
+ export interface DashboardProps {
36
+ tui: { requestRender: () => void }
37
+ theme: Theme
38
+ initialSessions: SessionRecord[]
39
+ onRefresh: () => Promise<SessionRecord[]>
40
+ onClose: () => void
41
+ }
42
+
43
+ function formatUptime(startedAt: number): string {
44
+ const elapsed = Date.now() - startedAt
45
+ const seconds = Math.floor(elapsed / 1000)
46
+ const minutes = Math.floor(seconds / 60)
47
+ const hours = Math.floor(minutes / 60)
48
+
49
+ if (hours > 0) {
50
+ const mins = minutes % 60
51
+ return `${hours}h ${mins}m`
52
+ }
53
+ if (minutes > 0) {
54
+ const secs = seconds % 60
55
+ return `${minutes}m ${secs}s`
56
+ }
57
+ return `${seconds}s`
58
+ }
59
+
60
+ export function createDashboard(props: DashboardProps) {
61
+ let sessions = [...props.initialSessions]
62
+ let scrollOffset = 0
63
+ let cachedWidth: number | null = null
64
+ let cachedLines: string[] = []
65
+
66
+ const dashboardContainer = new Container()
67
+
68
+ function updateChildren(width: number): void {
69
+ const remainingWidth = Math.max(1, width - FIXED_COLUMNS_WIDTH - GAPS_WIDTH)
70
+ const { theme } = props
71
+
72
+ const stats = sessions.reduce(
73
+ (acc, s) => {
74
+ acc[s.state]++
75
+ return acc
76
+ },
77
+ { running: 0, idle: 0 },
78
+ )
79
+
80
+ const headerLine = COLUMNS.map((col) =>
81
+ col.width === undefined ? col.name : col.name.padEnd(col.width),
82
+ ).join(' ')
83
+
84
+ dashboardContainer.clear()
85
+ dashboardContainer.addChild(
86
+ new Text(
87
+ `${theme.fg('accent', theme.bold('pi sessions dashboard'))} ${theme.fg(
88
+ 'dim',
89
+ `total=${sessions.length} running=${stats.running} idle=${stats.idle}`,
90
+ )}`,
91
+ 0,
92
+ 0,
93
+ ),
94
+ )
95
+ dashboardContainer.addChild(new Spacer(1))
96
+ dashboardContainer.addChild(
97
+ new Text(theme.fg('borderAccent', headerLine), 0, 0),
98
+ )
99
+ dashboardContainer.addChild(
100
+ new DynamicBorder((str) => theme.fg('borderAccent', str)),
101
+ )
102
+
103
+ for (const session of sessions.slice(
104
+ scrollOffset,
105
+ scrollOffset + MAX_ROWS,
106
+ )) {
107
+ const stateColor = session.state === 'running' ? 'success' : 'muted'
108
+ const line = [
109
+ theme.fg('dim', String(session.pid).padEnd(PID_COL.width)),
110
+ theme.fg(stateColor, session.state.padEnd(STATE_COL.width)),
111
+ theme.fg(
112
+ 'text',
113
+ truncateToWidth(session.projectName, PROJECT_COL.width, '…', true),
114
+ ),
115
+ theme.fg(
116
+ 'dim',
117
+ formatUptime(session.startedAt).padEnd(UPTIME_COL.width),
118
+ ),
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
+ ].join(' ')
135
+ dashboardContainer.addChild(new Text(line, 0, 0))
136
+ }
137
+
138
+ dashboardContainer.addChild(new Spacer(1))
139
+ dashboardContainer.addChild(
140
+ new Text(theme.fg('dim', '↑↓ scroll • r refresh • q or esc close'), 0, 0),
141
+ )
142
+ }
143
+
144
+ const component = {
145
+ render(width: number): string[] {
146
+ if (cachedWidth !== width) {
147
+ updateChildren(width)
148
+ cachedLines = dashboardContainer.render(width)
149
+ cachedWidth = width
150
+ }
151
+ return cachedLines
152
+ },
153
+
154
+ handleInput(data: string): void {
155
+ if (matchesKey(data, 'r')) {
156
+ props
157
+ .onRefresh()
158
+ .then((newSessions) => {
159
+ sessions = [...newSessions]
160
+ scrollOffset = 0
161
+ cachedWidth = null
162
+ props.tui.requestRender()
163
+ })
164
+ .catch(() => {})
165
+ return
166
+ }
167
+
168
+ if (matchesKey(data, 'q') || matchesKey(data, Key.escape)) {
169
+ props.onClose()
170
+ return
171
+ }
172
+
173
+ if (matchesKey(data, Key.up) || matchesKey(data, 'k')) {
174
+ if (scrollOffset > 0) {
175
+ scrollOffset--
176
+ cachedWidth = null
177
+ props.tui.requestRender()
178
+ }
179
+ return
180
+ }
181
+
182
+ if (matchesKey(data, Key.down) || matchesKey(data, 'j')) {
183
+ if (scrollOffset + MAX_ROWS < sessions.length) {
184
+ scrollOffset++
185
+ cachedWidth = null
186
+ props.tui.requestRender()
187
+ }
188
+ }
189
+ },
190
+
191
+ invalidate(): void {
192
+ cachedWidth = null
193
+ dashboardContainer.invalidate()
194
+ },
195
+ }
196
+
197
+ return component
198
+ }
package/src/idle.ts CHANGED
@@ -36,6 +36,10 @@ export class IdleNotifier {
36
36
  this.clearIdleTimer()
37
37
  })
38
38
 
39
+ this.pi.on('message_start', () => {
40
+ this.clearIdleTimer()
41
+ })
42
+
39
43
  this.pi.on('agent_settled', () => {
40
44
  if (this.jobTracker.hasActiveJobs) return
41
45
  this.startIdleTimer(notify)
package/src/index.ts CHANGED
@@ -3,6 +3,8 @@ import { basename } from 'node:path'
3
3
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
4
4
 
5
5
  import { loadConfig } from './config.js'
6
+ import { DashboardCommand } from './dashboard/command.js'
7
+ import { StateTracker } from './dashboard/state-tracker.js'
6
8
  import { EventsNotifier } from './events.js'
7
9
  import { FocusTracker } from './focus.js'
8
10
  import { IdleNotifier } from './idle.js'
@@ -29,6 +31,8 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
29
31
  const idleNotifier = new IdleNotifier(pi, config, jobTracker)
30
32
  const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
31
33
  const sessionState = new SessionState(pi)
34
+ const stateTracker = new StateTracker(pi)
35
+ const dashboardCommand = new DashboardCommand(pi)
32
36
 
33
37
  function notifyReal(body: string): void {
34
38
  if (!config.enabled || !sessionState.hasUI) return
@@ -51,4 +55,6 @@ export default function piNotifyExtension(pi: ExtensionAPI): void {
51
55
  toolNotifier.register(notifyReal)
52
56
  idleNotifier.register(notifyReal)
53
57
  notifyTest.register()
58
+ stateTracker.register()
59
+ dashboardCommand.register()
54
60
  }
package/src/tmux-title.ts CHANGED
@@ -29,8 +29,7 @@ export class TmuxTitleTracker {
29
29
  register(): void {
30
30
  this.pi.on('session_start', (_event, ctx) => {
31
31
  if (ctx.mode !== 'tui' || !this.enabled) return
32
- // Resolve pi's own window up front via its controlling tty, so mark/restore
33
- // keep targeting it even if the user switches windows during /new.
32
+ this.restore()
34
33
  const id = this.queryCurrentWindowId()
35
34
  if (id === undefined) return
36
35
  this.windowId = id
@@ -74,6 +73,7 @@ export class TmuxTitleTracker {
74
73
  }
75
74
 
76
75
  stop(): void {
76
+ this.restore()
77
77
  this.windowId = undefined
78
78
  this.originalTitle = undefined
79
79
  this.autoRename = undefined