@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 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
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ export type Unsubscribe = () => void
2
+
3
+ export type NotifyAction = (body: string) => void
package/src/utils.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function sleep(ms: number): Promise<void> {
2
+ return new Promise((resolve) => setTimeout(resolve, ms))
3
+ }