@raidou/pi-notify 0.2.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 +92 -0
- package/package.json +48 -0
- package/src/config.ts +77 -0
- package/src/events.ts +46 -0
- package/src/focus.ts +78 -0
- package/src/idle.ts +48 -0
- package/src/index.ts +54 -0
- package/src/jobs.ts +70 -0
- package/src/node-notifier.d.ts +21 -0
- package/src/notifier.ts +55 -0
- package/src/notify-test.ts +26 -0
- package/src/states.ts +18 -0
- package/src/tmux-title.ts +221 -0
- package/src/tool.ts +18 -0
- package/src/types.ts +3 -0
- package/src/utils.ts +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @raidou/pi-notify
|
|
2
|
+
|
|
3
|
+
A notification extension for the [pi](https://github.com/earendil-works/pi-coding-agent) coding agent.
|
|
4
|
+
|
|
5
|
+
`@raidou/pi-notify` fires a native desktop notification on idle, configured tool calls (e.g., `Tool call: ask_user`), and custom pi events (default: `permissions:ui_prompt`).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@raidou/pi-notify
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or, for local development, add the repo path to your `~/.pi/agent/settings.json`:
|
|
14
|
+
|
|
15
|
+
```jsonc
|
|
16
|
+
{
|
|
17
|
+
"extensions": ["/absolute/path/to/pi-notify"],
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What triggers a notification
|
|
22
|
+
|
|
23
|
+
| Event | Source | Default body |
|
|
24
|
+
| ----------------- | ----------------------------------------------------------- | ------------------------------------------- |
|
|
25
|
+
| **Finished** | `agent_settled` (pi idle, no active jobs) | `Idle` |
|
|
26
|
+
| **Tool calls** | `tool_call` on tools in `notifyTools` | `Tool call: <toolName>` |
|
|
27
|
+
| **Custom events** | Custom pi event channels (default: `permissions:ui_prompt`) | Customizable (default: `Permission prompt`) |
|
|
28
|
+
|
|
29
|
+
### Job tracking for background tasks
|
|
30
|
+
|
|
31
|
+
Extensions running background tasks can prevent spurious "Idle" notifications by emitting job lifecycle events. Notifications on `agent_settled` are suppressed while jobs are active. Custom event notifications are soft dependencies: if a package that broadcasts a specific event is not installed, that notification is skipped.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { JOB_START_EVENT, JOB_END_EVENT } from '@raidou/pi-notify'
|
|
35
|
+
|
|
36
|
+
function startBackgroundJob(jobId: string): void {
|
|
37
|
+
pi.events.emit(JOB_START_EVENT, { id: jobId })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function endBackgroundJob(jobId: string): void {
|
|
41
|
+
pi.events.emit(JOB_END_EVENT, { id: jobId })
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Events are automatically cleaned up on `session_shutdown`.
|
|
46
|
+
|
|
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
|
+
## Testing
|
|
84
|
+
|
|
85
|
+
Run `/notify-test` inside pi to fire a test notification.
|
|
86
|
+
Pass a string argument (e.g., `/notify-test hello`) to override the body.
|
|
87
|
+
|
|
88
|
+
## Platform support
|
|
89
|
+
|
|
90
|
+
`@raidou/pi-notify` prefers `node-notifier` (macOS Notification Center, Linux `notify-send`, native Windows toaster).
|
|
91
|
+
|
|
92
|
+
**WSL2 note:** `node-notifier` reports `process.platform === "linux"` and would route to `notify-send`, which is usually not installed under WSL and fails silently. `@raidou/pi-notify` detects WSL and raises a Windows toast via `powershell.exe` directly, so notifications reach the Windows Action Center out of the box.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raidou/pi-notify",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Desktop notification extension for the pi coding agent.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"notifications",
|
|
11
|
+
"desktop",
|
|
12
|
+
"wsl"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/weirongxu/pi-notify.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/weirongxu/pi-notify",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"files": [
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./src/index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"main": "./src/index.ts",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "pnpm run test:types && pnpm run test:lint",
|
|
32
|
+
"test:types": "tsc --noEmit",
|
|
33
|
+
"test:lint": "eslint --fix ."
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"node-notifier": "^10.0.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.79.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@raidou/eslint-config-base": "^4.4.3",
|
|
43
|
+
"@types/node": "^22.0.0",
|
|
44
|
+
"eslint": "^10.7.0",
|
|
45
|
+
"prettier": "^3.9.5",
|
|
46
|
+
"typescript": "^5.6.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
interface NotifyEventsConfig {
|
|
7
|
+
readonly [channel: string]: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NotifyConfig {
|
|
11
|
+
readonly enabled?: boolean
|
|
12
|
+
readonly notifyTools?: readonly string[]
|
|
13
|
+
readonly events?: NotifyEventsConfig
|
|
14
|
+
readonly finished?: boolean
|
|
15
|
+
readonly finishedThrottleSecs?: number
|
|
16
|
+
readonly onlyNotifyWhenUnfocused?: boolean
|
|
17
|
+
readonly unfocusedActivityThresholdSecs?: number
|
|
18
|
+
readonly tmuxSymbol?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedNotifyConfig {
|
|
22
|
+
readonly enabled: boolean
|
|
23
|
+
readonly notifyTools: ReadonlySet<string>
|
|
24
|
+
readonly events: NotifyEventsConfig
|
|
25
|
+
readonly finished: boolean
|
|
26
|
+
readonly finishedThrottleMs: number
|
|
27
|
+
readonly onlyNotifyWhenUnfocused: boolean
|
|
28
|
+
readonly unfocusedActivityThresholdMs: number
|
|
29
|
+
readonly tmuxSymbol: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default tool names that trigger notifications.
|
|
34
|
+
*
|
|
35
|
+
* Note: These are built-in pi tool names. If pi renames these tools, the default should be updated.
|
|
36
|
+
* Source: @earendil-works/pi-coding-agent
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_NOTIFY_TOOLS = ['ask_user', 'ask_user_question'] as const
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TMUX_SYMBOL = '🔔'
|
|
41
|
+
|
|
42
|
+
const DEFAULT_EVENTS: NotifyEventsConfig = {
|
|
43
|
+
'permissions:ui_prompt': 'Permission prompt',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SETTINGS_PATH = join(getAgentDir(), 'settings.json')
|
|
47
|
+
|
|
48
|
+
function readRawConfig(): NotifyConfig {
|
|
49
|
+
if (!existsSync(SETTINGS_PATH)) return {}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')) as {
|
|
52
|
+
piNotify?: NotifyConfig
|
|
53
|
+
}
|
|
54
|
+
return parsed.piNotify ?? {}
|
|
55
|
+
} catch {
|
|
56
|
+
// Malformed settings.json must not break the agent; fall back to defaults.
|
|
57
|
+
return {}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadConfig(): ResolvedNotifyConfig {
|
|
62
|
+
const cfg = readRawConfig()
|
|
63
|
+
const events = cfg.events ?? DEFAULT_EVENTS
|
|
64
|
+
return {
|
|
65
|
+
enabled: cfg.enabled ?? true,
|
|
66
|
+
notifyTools: new Set(cfg.notifyTools ?? DEFAULT_NOTIFY_TOOLS),
|
|
67
|
+
events,
|
|
68
|
+
finished: cfg.finished ?? true,
|
|
69
|
+
finishedThrottleMs: Math.max(0, (cfg.finishedThrottleSecs ?? 0) * 1000),
|
|
70
|
+
onlyNotifyWhenUnfocused: cfg.onlyNotifyWhenUnfocused ?? true,
|
|
71
|
+
unfocusedActivityThresholdMs: Math.max(
|
|
72
|
+
0,
|
|
73
|
+
(cfg.unfocusedActivityThresholdSecs ?? 30) * 1000,
|
|
74
|
+
),
|
|
75
|
+
tmuxSymbol: cfg.tmuxSymbol ?? DEFAULT_TMUX_SYMBOL,
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import type { NotifyAction, Unsubscribe } from './types.js'
|
|
5
|
+
|
|
6
|
+
export const PI_NOTIFY_EVENT = 'pi-notify:notify'
|
|
7
|
+
|
|
8
|
+
export class EventsNotifier {
|
|
9
|
+
private unsubscribes: Unsubscribe[] = []
|
|
10
|
+
private registered = false
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly pi: ExtensionAPI,
|
|
14
|
+
private readonly config: ResolvedNotifyConfig,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
register(notify: NotifyAction): void {
|
|
18
|
+
if (this.registered) return
|
|
19
|
+
this.registered = true
|
|
20
|
+
|
|
21
|
+
for (const [channel, message] of Object.entries(this.config.events)) {
|
|
22
|
+
if (!message) continue
|
|
23
|
+
const unsubscribe = this.pi.events.on(channel, () => {
|
|
24
|
+
notify(message)
|
|
25
|
+
})
|
|
26
|
+
this.unsubscribes.push(unsubscribe)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const customEventUnsub = this.pi.events.on(PI_NOTIFY_EVENT, (payload) => {
|
|
30
|
+
notify(String(payload))
|
|
31
|
+
})
|
|
32
|
+
this.unsubscribes.push(customEventUnsub)
|
|
33
|
+
|
|
34
|
+
this.pi.on('session_shutdown', () => {
|
|
35
|
+
this.stop()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
stop(): void {
|
|
40
|
+
this.unsubscribes.forEach((unsub) => {
|
|
41
|
+
unsub()
|
|
42
|
+
})
|
|
43
|
+
this.unsubscribes = []
|
|
44
|
+
this.registered = false
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/focus.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
+
import type { Unsubscribe } from './types.js'
|
|
6
|
+
|
|
7
|
+
// xterm focus reporting (CSI ?1004): emitted by the terminal on focus gain/loss.
|
|
8
|
+
const FOCUS_IN = '\x1b[I'
|
|
9
|
+
const FOCUS_OUT = '\x1b[O'
|
|
10
|
+
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h'
|
|
11
|
+
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'
|
|
12
|
+
|
|
13
|
+
export class FocusTracker {
|
|
14
|
+
private _focused: boolean | undefined = undefined
|
|
15
|
+
private _lastActivityAt = Date.now()
|
|
16
|
+
private unsubscribe: Unsubscribe | undefined
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly pi: ExtensionAPI,
|
|
20
|
+
private readonly titleTracker: TmuxTitleTracker,
|
|
21
|
+
private readonly config: ResolvedNotifyConfig,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
get isFocused(): boolean | undefined {
|
|
25
|
+
return this._focused
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Timestamp of the last observed terminal input (fallback focus signal). */
|
|
29
|
+
get lastActivityAt(): number {
|
|
30
|
+
return this._lastActivityAt
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
register(): void {
|
|
34
|
+
this.pi.on('session_start', (_event, ctx) => {
|
|
35
|
+
const activate =
|
|
36
|
+
ctx.mode === 'tui' &&
|
|
37
|
+
(this.titleTracker.enabled || this.config.onlyNotifyWhenUnfocused)
|
|
38
|
+
if (!activate) return
|
|
39
|
+
this._lastActivityAt = Date.now()
|
|
40
|
+
process.stdout.write(ENABLE_FOCUS_REPORTING)
|
|
41
|
+
this.unsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
42
|
+
this._lastActivityAt = Date.now()
|
|
43
|
+
const result = this.consume(data)
|
|
44
|
+
if (result.gainedFocus) this.titleTracker.restore()
|
|
45
|
+
return result.data === data ? undefined : { data: result.data }
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
this.pi.on('session_shutdown', () => {
|
|
49
|
+
this.stop()
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop(): void {
|
|
54
|
+
if (this.unsubscribe) {
|
|
55
|
+
process.stdout.write(DISABLE_FOCUS_REPORTING)
|
|
56
|
+
this.unsubscribe()
|
|
57
|
+
this.unsubscribe = undefined
|
|
58
|
+
}
|
|
59
|
+
this._focused = undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private consume(data: string): { data: string; gainedFocus: boolean } {
|
|
63
|
+
let current = data
|
|
64
|
+
let gainedFocus = false
|
|
65
|
+
|
|
66
|
+
if (current.includes(FOCUS_IN)) {
|
|
67
|
+
this._focused = true
|
|
68
|
+
gainedFocus = true
|
|
69
|
+
current = current.split(FOCUS_IN).join('')
|
|
70
|
+
}
|
|
71
|
+
if (current.includes(FOCUS_OUT)) {
|
|
72
|
+
this._focused = false
|
|
73
|
+
current = current.split(FOCUS_OUT).join('')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { data: current, gainedFocus }
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/idle.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import type { JobTracker } from './jobs.js'
|
|
5
|
+
import type { NotifyAction } from './types.js'
|
|
6
|
+
|
|
7
|
+
const IDLE_TIMEOUT_MS = 10000
|
|
8
|
+
|
|
9
|
+
export class IdleNotifier {
|
|
10
|
+
private timer: NodeJS.Timeout | null = null
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly pi: ExtensionAPI,
|
|
14
|
+
private readonly config: ResolvedNotifyConfig,
|
|
15
|
+
private readonly jobTracker: JobTracker,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
clearIdleTimer(): void {
|
|
19
|
+
if (this.timer) {
|
|
20
|
+
clearTimeout(this.timer)
|
|
21
|
+
this.timer = null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
startIdleTimer(notify: NotifyAction): void {
|
|
25
|
+
this.clearIdleTimer()
|
|
26
|
+
this.timer = setTimeout(() => {
|
|
27
|
+
this.clearIdleTimer()
|
|
28
|
+
notify('Idle')
|
|
29
|
+
}, IDLE_TIMEOUT_MS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
register(notify: NotifyAction): void {
|
|
33
|
+
if (!this.config.finished) return
|
|
34
|
+
|
|
35
|
+
this.pi.on('turn_start', () => {
|
|
36
|
+
this.clearIdleTimer()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
this.pi.on('agent_settled', () => {
|
|
40
|
+
if (this.jobTracker.hasActiveJobs) return
|
|
41
|
+
this.startIdleTimer(notify)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
this.jobTracker.onEnd(() => {
|
|
45
|
+
this.startIdleTimer(notify)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
|
|
5
|
+
import { loadConfig } from './config.js'
|
|
6
|
+
import { EventsNotifier } from './events.js'
|
|
7
|
+
import { FocusTracker } from './focus.js'
|
|
8
|
+
import { IdleNotifier } from './idle.js'
|
|
9
|
+
import { JobTracker } from './jobs.js'
|
|
10
|
+
import { notify } from './notifier.js'
|
|
11
|
+
import { NotifyTest } from './notify-test.js'
|
|
12
|
+
import { SessionState } from './states.js'
|
|
13
|
+
import { TmuxTitleTracker } from './tmux-title.js'
|
|
14
|
+
import { ToolCallNotifier } from './tool.js'
|
|
15
|
+
|
|
16
|
+
export { PI_NOTIFY_EVENT } from './events.js'
|
|
17
|
+
export { JOB_END_EVENT, JOB_START_EVENT } from './jobs.js'
|
|
18
|
+
|
|
19
|
+
export default function piNotifyExtension(pi: ExtensionAPI): void {
|
|
20
|
+
const config = loadConfig()
|
|
21
|
+
const dirName = basename(process.cwd())
|
|
22
|
+
const title = `pi — ${dirName}`
|
|
23
|
+
|
|
24
|
+
const tmuxTitleTracker = new TmuxTitleTracker(pi, config)
|
|
25
|
+
const focusTracker = new FocusTracker(pi, tmuxTitleTracker, config)
|
|
26
|
+
const eventsNotifier = new EventsNotifier(pi, config)
|
|
27
|
+
const toolNotifier = new ToolCallNotifier(pi, config)
|
|
28
|
+
const jobTracker = new JobTracker(pi)
|
|
29
|
+
const idleNotifier = new IdleNotifier(pi, config, jobTracker)
|
|
30
|
+
const notifyTest = new NotifyTest(pi, title, tmuxTitleTracker)
|
|
31
|
+
const sessionState = new SessionState(pi)
|
|
32
|
+
|
|
33
|
+
function notifyReal(body: string): void {
|
|
34
|
+
if (!config.enabled || !sessionState.hasUI) return
|
|
35
|
+
if (config.onlyNotifyWhenUnfocused) {
|
|
36
|
+
// Prefer the real focus state; fall back to inactivity timing for
|
|
37
|
+
// terminals that lack focus reporting.
|
|
38
|
+
const recentlyActive =
|
|
39
|
+
Date.now() - focusTracker.lastActivityAt <=
|
|
40
|
+
config.unfocusedActivityThresholdMs
|
|
41
|
+
if (focusTracker.isFocused ?? recentlyActive) return
|
|
42
|
+
}
|
|
43
|
+
tmuxTitleTracker.mark()
|
|
44
|
+
notify(title, body)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
tmuxTitleTracker.register()
|
|
48
|
+
focusTracker.register()
|
|
49
|
+
jobTracker.register()
|
|
50
|
+
eventsNotifier.register(notifyReal)
|
|
51
|
+
toolNotifier.register(notifyReal)
|
|
52
|
+
idleNotifier.register(notifyReal)
|
|
53
|
+
notifyTest.register()
|
|
54
|
+
}
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import type { Unsubscribe } from './types.js'
|
|
4
|
+
|
|
5
|
+
export const JOB_START_EVENT = 'pi-notify:job:start'
|
|
6
|
+
export const JOB_END_EVENT = 'pi-notify:job:end'
|
|
7
|
+
|
|
8
|
+
export class JobTracker {
|
|
9
|
+
private activeJobs = new Set<string>()
|
|
10
|
+
private unsubscribes: Unsubscribe[] = []
|
|
11
|
+
private registered = false
|
|
12
|
+
private onEndListeners: Array<() => void> = []
|
|
13
|
+
|
|
14
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
15
|
+
|
|
16
|
+
get hasActiveJobs(): boolean {
|
|
17
|
+
return this.activeJobs.size > 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
onEnd(listener: () => void): () => void {
|
|
21
|
+
this.onEndListeners.push(listener)
|
|
22
|
+
return () => {
|
|
23
|
+
const index = this.onEndListeners.indexOf(listener)
|
|
24
|
+
if (index !== -1) this.onEndListeners.splice(index, 1)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
register(): void {
|
|
29
|
+
if (this.registered) return
|
|
30
|
+
this.registered = true
|
|
31
|
+
|
|
32
|
+
const startUnsub = this.pi.events.on(JOB_START_EVENT, (params) => {
|
|
33
|
+
if (
|
|
34
|
+
typeof params === 'object' &&
|
|
35
|
+
params !== null &&
|
|
36
|
+
'id' in params &&
|
|
37
|
+
typeof params.id === 'string'
|
|
38
|
+
) {
|
|
39
|
+
this.activeJobs.add(params.id)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
this.unsubscribes.push(startUnsub)
|
|
43
|
+
|
|
44
|
+
const endUnsub = this.pi.events.on(JOB_END_EVENT, (params) => {
|
|
45
|
+
if (
|
|
46
|
+
typeof params === 'object' &&
|
|
47
|
+
params !== null &&
|
|
48
|
+
'id' in params &&
|
|
49
|
+
typeof params.id === 'string'
|
|
50
|
+
) {
|
|
51
|
+
for (const listener of this.onEndListeners) listener()
|
|
52
|
+
this.activeJobs.delete(params.id)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
this.unsubscribes.push(endUnsub)
|
|
56
|
+
|
|
57
|
+
this.pi.on('session_shutdown', () => {
|
|
58
|
+
this.stop()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stop(): void {
|
|
63
|
+
this.unsubscribes.forEach((unsub) => {
|
|
64
|
+
unsub()
|
|
65
|
+
})
|
|
66
|
+
this.unsubscribes = []
|
|
67
|
+
this.activeJobs.clear()
|
|
68
|
+
this.registered = false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal type declaration for `node-notifier` (ships without bundled types).
|
|
3
|
+
* Only the surface this extension uses is modeled.
|
|
4
|
+
*/
|
|
5
|
+
declare module 'node-notifier' {
|
|
6
|
+
export interface NodeNotifierOptions {
|
|
7
|
+
title?: string
|
|
8
|
+
message: string
|
|
9
|
+
wait?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NodeNotifier {
|
|
13
|
+
notify(
|
|
14
|
+
options: NodeNotifierOptions,
|
|
15
|
+
callback?: (error: Error | null) => void,
|
|
16
|
+
): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const notifier: NodeNotifier
|
|
20
|
+
export default notifier
|
|
21
|
+
}
|
package/src/notifier.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WSL reports `process.platform === "linux"` but the user's desktop is Windows.
|
|
6
|
+
* node-notifier would route to `notify-send` (not installed) and silently fail,
|
|
7
|
+
* so we bypass it and call `powershell.exe` directly to raise a Windows toast.
|
|
8
|
+
*/
|
|
9
|
+
const isWSL = detectWSL()
|
|
10
|
+
|
|
11
|
+
function detectWSL(): boolean {
|
|
12
|
+
if (process.platform !== 'linux') return false
|
|
13
|
+
if (process.env.WSL_DISTRO_NAME ?? process.env.WT_SESSION) return true
|
|
14
|
+
try {
|
|
15
|
+
return /microsoft/i.test(readFileSync('/proc/version', 'utf8'))
|
|
16
|
+
} catch {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function escapePowerShell(value: string): string {
|
|
22
|
+
return value.replace(/'/g, "''").replace(/\r?\n/g, ' ')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function windowsToast(title: string, body: string): void {
|
|
26
|
+
const app = escapePowerShell(title)
|
|
27
|
+
const text = escapePowerShell(body)
|
|
28
|
+
// `.Item(0)` (not the `[0]` indexer) avoids a PowerShell 5.1 enumeration
|
|
29
|
+
// quirk that throws "Collection was modified" on the live XmlNodeList.
|
|
30
|
+
const script = [
|
|
31
|
+
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null',
|
|
32
|
+
`$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText01)`,
|
|
33
|
+
`[void] $t.GetElementsByTagName('text').Item(0).AppendChild($t.CreateTextNode('${text}'))`,
|
|
34
|
+
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${app}').Show([Windows.UI.Notifications.ToastNotification]::new($t))`,
|
|
35
|
+
].join('; ')
|
|
36
|
+
const exe = process.platform === 'win32' ? 'powershell' : 'powershell.exe'
|
|
37
|
+
execFile(exe, ['-NoProfile', '-NonInteractive', '-Command', script], () => {})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function piNotify(title: string, body: string): Promise<void> {
|
|
41
|
+
if (isWSL || process.platform === 'win32') {
|
|
42
|
+
windowsToast(title, body)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const notifier = (await import('node-notifier')).default
|
|
46
|
+
await new Promise<void>((resolve) => {
|
|
47
|
+
notifier.notify({ title, message: body, wait: false }, () => {
|
|
48
|
+
resolve()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function notify(title: string, body: string): void {
|
|
54
|
+
void piNotify(title, body).catch(() => {})
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import { notify } from './notifier.js'
|
|
4
|
+
import type { TmuxTitleTracker } from './tmux-title.js'
|
|
5
|
+
import { sleep } from './utils.js'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BODY = 'This is a test notification.'
|
|
8
|
+
|
|
9
|
+
export class NotifyTest {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly pi: ExtensionAPI,
|
|
12
|
+
private readonly title: string,
|
|
13
|
+
private readonly titleTracker: TmuxTitleTracker,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
register(): void {
|
|
17
|
+
this.pi.registerCommand('notify-test', {
|
|
18
|
+
description: 'Fire a test notification',
|
|
19
|
+
handler: async (args) => {
|
|
20
|
+
await sleep(3000)
|
|
21
|
+
this.titleTracker.mark()
|
|
22
|
+
notify(this.title, args.trim() || DEFAULT_BODY)
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/states.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
export class SessionState {
|
|
4
|
+
public hasUI = false
|
|
5
|
+
|
|
6
|
+
constructor(private readonly pi: ExtensionAPI) {
|
|
7
|
+
this.register()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
private register(): void {
|
|
11
|
+
this.pi.on('session_start', (_event, ctx) => {
|
|
12
|
+
this.hasUI = ctx.hasUI
|
|
13
|
+
})
|
|
14
|
+
this.pi.on('session_shutdown', () => {
|
|
15
|
+
this.hasUI = false
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { readlinkSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
import type { ResolvedNotifyConfig } from './config.js'
|
|
7
|
+
|
|
8
|
+
const WINDOW_ID_FORMAT = '#{window_id}'
|
|
9
|
+
const WINDOW_NAME_FORMAT = '#{window_name}'
|
|
10
|
+
const LIST_FORMAT = '#{pane_tty}\t#{window_id}'
|
|
11
|
+
const NEWLINE = '\n'
|
|
12
|
+
const TAB = '\t'
|
|
13
|
+
|
|
14
|
+
export class TmuxTitleTracker {
|
|
15
|
+
private windowId: string | undefined
|
|
16
|
+
private originalTitle: string | undefined
|
|
17
|
+
private autoRename: boolean | undefined
|
|
18
|
+
private modified = false
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly pi: ExtensionAPI,
|
|
22
|
+
private readonly config: ResolvedNotifyConfig,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
get enabled(): boolean {
|
|
26
|
+
return this.config.tmuxSymbol.length > 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register(): void {
|
|
30
|
+
this.pi.on('session_start', (_event, ctx) => {
|
|
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.
|
|
34
|
+
const id = this.queryCurrentWindowId()
|
|
35
|
+
if (id === undefined) return
|
|
36
|
+
this.windowId = id
|
|
37
|
+
this.originalTitle = this.queryWindowName(id)
|
|
38
|
+
this.autoRename = this.queryAutomaticRename(id)
|
|
39
|
+
})
|
|
40
|
+
this.pi.on('session_shutdown', () => {
|
|
41
|
+
this.stop()
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
mark(): void {
|
|
46
|
+
if (
|
|
47
|
+
!this.enabled ||
|
|
48
|
+
this.modified ||
|
|
49
|
+
this.windowId === undefined ||
|
|
50
|
+
this.originalTitle === undefined
|
|
51
|
+
)
|
|
52
|
+
return
|
|
53
|
+
this.renameWindow(
|
|
54
|
+
this.windowId,
|
|
55
|
+
`${this.originalTitle}${this.config.tmuxSymbol}`,
|
|
56
|
+
)
|
|
57
|
+
this.modified = true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
restore(): void {
|
|
61
|
+
if (
|
|
62
|
+
!this.modified ||
|
|
63
|
+
this.windowId === undefined ||
|
|
64
|
+
this.originalTitle === undefined
|
|
65
|
+
)
|
|
66
|
+
return
|
|
67
|
+
this.renameWindow(this.windowId, this.originalTitle)
|
|
68
|
+
if (this.autoRename === undefined) {
|
|
69
|
+
this.unsetAutomaticRename(this.windowId)
|
|
70
|
+
} else {
|
|
71
|
+
this.setAutomaticRename(this.windowId, this.autoRename)
|
|
72
|
+
}
|
|
73
|
+
this.modified = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stop(): void {
|
|
77
|
+
this.windowId = undefined
|
|
78
|
+
this.originalTitle = undefined
|
|
79
|
+
this.autoRename = undefined
|
|
80
|
+
this.modified = false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private queryCurrentWindowId(): string | undefined {
|
|
84
|
+
if (process.env.TMUX === undefined) return undefined
|
|
85
|
+
const tty = this.ourTty()
|
|
86
|
+
if (tty !== undefined) {
|
|
87
|
+
const id = this.findWindowByTty(tty)
|
|
88
|
+
if (id !== undefined) return id
|
|
89
|
+
}
|
|
90
|
+
return this.queryActiveWindowId()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private queryWindowName(windowId: string): string | undefined {
|
|
94
|
+
try {
|
|
95
|
+
const name = execFileSync(
|
|
96
|
+
'tmux',
|
|
97
|
+
['display-message', '-t', windowId, '-p', WINDOW_NAME_FORMAT],
|
|
98
|
+
{
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
return name.trim() || undefined
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private renameWindow(windowId: string, name: string): void {
|
|
110
|
+
try {
|
|
111
|
+
execFileSync('tmux', ['rename-window', '-t', windowId, name], {
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
})
|
|
114
|
+
} catch {
|
|
115
|
+
// best-effort; the window title is non-critical
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private ourTty(): string | undefined {
|
|
120
|
+
for (const fd of ['0', '1', '2']) {
|
|
121
|
+
try {
|
|
122
|
+
const target = readlinkSync(`/proc/self/fd/${fd}`)
|
|
123
|
+
if (target.startsWith('/dev/')) return target
|
|
124
|
+
} catch {
|
|
125
|
+
// fd is not a tty, or platform without /proc/self/fd
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private findWindowByTty(tty: string): string | undefined {
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync(
|
|
134
|
+
'tmux',
|
|
135
|
+
['list-panes', '-a', '-F', LIST_FORMAT],
|
|
136
|
+
{
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
for (const line of out.split(NEWLINE)) {
|
|
142
|
+
const [paneTty, windowId] = line.split(TAB)
|
|
143
|
+
if (paneTty === tty) return windowId
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
} catch {
|
|
147
|
+
return undefined
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private queryActiveWindowId(): string | undefined {
|
|
152
|
+
try {
|
|
153
|
+
const id = execFileSync(
|
|
154
|
+
'tmux',
|
|
155
|
+
['display-message', '-p', WINDOW_ID_FORMAT],
|
|
156
|
+
{
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
return id.trim() || undefined
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private queryAutomaticRename(windowId: string): boolean | undefined {
|
|
168
|
+
try {
|
|
169
|
+
const value = execFileSync(
|
|
170
|
+
'tmux',
|
|
171
|
+
['show-window-options', '-t', windowId, '-v', 'automatic-rename'],
|
|
172
|
+
{
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
const trimmed = value.trim()
|
|
178
|
+
// Empty output means no window-local override exists; the window
|
|
179
|
+
// inherits the global option. Restore by unsetting, not by forcing
|
|
180
|
+
// a value, so it keeps inheriting whatever the global is.
|
|
181
|
+
if (trimmed === '') return undefined
|
|
182
|
+
return trimmed === 'on'
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private setAutomaticRename(windowId: string, enabled: boolean): void {
|
|
189
|
+
try {
|
|
190
|
+
execFileSync(
|
|
191
|
+
'tmux',
|
|
192
|
+
[
|
|
193
|
+
'set-window-option',
|
|
194
|
+
'-t',
|
|
195
|
+
windowId,
|
|
196
|
+
'automatic-rename',
|
|
197
|
+
enabled ? 'on' : 'off',
|
|
198
|
+
],
|
|
199
|
+
{
|
|
200
|
+
stdio: 'ignore',
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
} catch {
|
|
204
|
+
// best-effort; the window title is non-critical
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private unsetAutomaticRename(windowId: string): void {
|
|
209
|
+
try {
|
|
210
|
+
execFileSync(
|
|
211
|
+
'tmux',
|
|
212
|
+
['set-window-option', '-t', windowId, '-u', 'automatic-rename'],
|
|
213
|
+
{
|
|
214
|
+
stdio: 'ignore',
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
} catch {
|
|
218
|
+
// best-effort; the window title is non-critical
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import type { ResolvedNotifyConfig } from './config.js'
|
|
4
|
+
import type { NotifyAction } from './types.js'
|
|
5
|
+
|
|
6
|
+
export class ToolCallNotifier {
|
|
7
|
+
constructor(
|
|
8
|
+
private readonly pi: ExtensionAPI,
|
|
9
|
+
private readonly config: ResolvedNotifyConfig,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
register(notify: NotifyAction): void {
|
|
13
|
+
this.pi.on('tool_call', (event) => {
|
|
14
|
+
if (!this.config.notifyTools.has(event.toolName)) return
|
|
15
|
+
notify(`Tool call: ${event.toolName}`)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/types.ts
ADDED
package/src/utils.ts
ADDED