@clang.engineer/pi-notify 0.1.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 +31 -0
- package/package.json +24 -0
- package/src/index.ts +197 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# @clang-engineer/pi-notify
|
|
2
|
+
|
|
3
|
+
Notification extension for Pi coding agent.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Notifies on `agent_settled`.
|
|
8
|
+
- Notifies on `permissions:ask`.
|
|
9
|
+
- Backend: `auto`, `macos`, `terminal`, or `off`.
|
|
10
|
+
- Optional last-input summary in completion notifications.
|
|
11
|
+
- Optional script hook.
|
|
12
|
+
|
|
13
|
+
## Config
|
|
14
|
+
|
|
15
|
+
Global: `~/.pi/agent/notify.json`
|
|
16
|
+
Project: `<project>/.pi/notify.json`
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"notify": {
|
|
21
|
+
"title": "Pi",
|
|
22
|
+
"settledMessage": "Agent 작업 완료",
|
|
23
|
+
"includeInput": true,
|
|
24
|
+
"maxInputLength": 80,
|
|
25
|
+
"permissionAsk": true,
|
|
26
|
+
"permissionMessage": "Permission required: {tool}",
|
|
27
|
+
"backend": "auto",
|
|
28
|
+
"skipWhenFrontmost": false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clang.engineer/pi-notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Completion and permission notifications for Pi coding agent",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"notification"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
type NotifyEvent = "agent_settled" | "permission_ask";
|
|
7
|
+
type Backend = "auto" | "macos" | "terminal" | "off";
|
|
8
|
+
|
|
9
|
+
type NotifyConfig = {
|
|
10
|
+
backend: Backend;
|
|
11
|
+
title: string;
|
|
12
|
+
settledMessage: string;
|
|
13
|
+
permissionMessage: string;
|
|
14
|
+
permissionAsk: boolean;
|
|
15
|
+
includeInput: boolean;
|
|
16
|
+
maxInputLength: number;
|
|
17
|
+
skipWhenFrontmost: boolean;
|
|
18
|
+
frontmostProcess: string;
|
|
19
|
+
script?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type NotifyPayload = {
|
|
23
|
+
event: NotifyEvent;
|
|
24
|
+
title: string;
|
|
25
|
+
message: string;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
cwd: string;
|
|
28
|
+
pid: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type RawConfig = Partial<NotifyConfig> & { notify?: Partial<NotifyConfig> };
|
|
32
|
+
|
|
33
|
+
const DEFAULT_CONFIG: NotifyConfig = {
|
|
34
|
+
backend: "auto",
|
|
35
|
+
title: "Pi",
|
|
36
|
+
settledMessage: "Agent 작업 완료",
|
|
37
|
+
permissionMessage: "Permission required: {tool}",
|
|
38
|
+
permissionAsk: true,
|
|
39
|
+
includeInput: true,
|
|
40
|
+
maxInputLength: 80,
|
|
41
|
+
skipWhenFrontmost: false,
|
|
42
|
+
frontmostProcess: "ghostty",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function execFileAsync(file: string, args: string[], input?: string, timeout = 5000): Promise<string> {
|
|
46
|
+
return new Promise((resolvePromise, reject) => {
|
|
47
|
+
const child = execFile(file, args, { timeout }, (error, stdout) => {
|
|
48
|
+
if (error) reject(error);
|
|
49
|
+
else resolvePromise(String(stdout || ""));
|
|
50
|
+
});
|
|
51
|
+
if (input !== undefined) child.stdin?.end(input);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function escapeAppleScript(value: string): string {
|
|
56
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function frontmostProcessName(): Promise<string | undefined> {
|
|
60
|
+
try {
|
|
61
|
+
return (
|
|
62
|
+
await execFileAsync("/usr/bin/osascript", [
|
|
63
|
+
"-e",
|
|
64
|
+
'tell application "System Events" to get name of first application process whose frontmost is true',
|
|
65
|
+
])
|
|
66
|
+
)
|
|
67
|
+
.trim()
|
|
68
|
+
.toLowerCase();
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function shouldSkip(config: NotifyConfig): Promise<boolean> {
|
|
75
|
+
if (!config.skipWhenFrontmost || process.platform !== "darwin") return false;
|
|
76
|
+
// App-level frontmost detection cannot distinguish tmux sessions/windows/panes.
|
|
77
|
+
// If Pi runs inside tmux, prefer notifying rather than incorrectly suppressing
|
|
78
|
+
// while the user is in another tmux context in the same terminal app.
|
|
79
|
+
if (process.env.TMUX) return false;
|
|
80
|
+
return (await frontmostProcessName()) === config.frontmostProcess.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function notifyTerminal(title: string, message: string): void {
|
|
84
|
+
if (process.env.KITTY_WINDOW_ID) {
|
|
85
|
+
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
|
|
86
|
+
process.stdout.write(`\x1b]99;i=1:p=body;${message}\x1b\\`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
process.stdout.write(`\x1b]777;notify;${title};${message}\x07`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function notifyMacos(title: string, message: string): Promise<void> {
|
|
93
|
+
await execFileAsync("/usr/bin/osascript", [
|
|
94
|
+
"-e",
|
|
95
|
+
`display notification "${escapeAppleScript(message)}" with title "${escapeAppleScript(title)}"`,
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function sendNotification(config: NotifyConfig, payload: NotifyPayload): Promise<void> {
|
|
100
|
+
if (config.backend === "off") return;
|
|
101
|
+
if (config.backend === "macos" || (config.backend === "auto" && process.platform === "darwin")) {
|
|
102
|
+
await notifyMacos(payload.title, payload.message);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
notifyTerminal(payload.title, payload.message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runScript(config: NotifyConfig, payload: NotifyPayload): Promise<void> {
|
|
109
|
+
if (!config.script) return;
|
|
110
|
+
await execFileAsync(config.script, [], JSON.stringify(payload), 5000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function configPaths(cwd: string): string[] {
|
|
114
|
+
return [join(homedir(), ".pi", "agent", "notify.json"), resolve(cwd, ".pi", "notify.json")];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readJson(path: string): RawConfig {
|
|
118
|
+
if (!existsSync(path)) return {};
|
|
119
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as RawConfig;
|
|
120
|
+
return typeof parsed.notify === "object" && parsed.notify !== null ? parsed.notify : parsed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadConfig(cwd: string): NotifyConfig {
|
|
124
|
+
const merged = Object.assign({}, ...configPaths(cwd).map(readJson)) as Partial<NotifyConfig>;
|
|
125
|
+
return {
|
|
126
|
+
...DEFAULT_CONFIG,
|
|
127
|
+
...merged,
|
|
128
|
+
backend: ["auto", "macos", "terminal", "off"].includes(String(merged.backend))
|
|
129
|
+
? (merged.backend as Backend)
|
|
130
|
+
: DEFAULT_CONFIG.backend,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function permissionToolName(value: unknown): string {
|
|
135
|
+
if (typeof value !== "object" || value === null) return "unknown";
|
|
136
|
+
const toolName = (value as { toolName?: unknown }).toolName;
|
|
137
|
+
return typeof toolName === "string" && toolName.trim() ? toolName : "unknown";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function summarizeInput(text: string, maxLength: number): string {
|
|
141
|
+
const compact = text.replace(/\s+/g, " ").trim();
|
|
142
|
+
if (compact.length <= maxLength) return compact;
|
|
143
|
+
return `${compact.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default function (pi: any) {
|
|
147
|
+
let cwd = process.cwd();
|
|
148
|
+
let config = loadConfig(cwd);
|
|
149
|
+
let lastInput = "";
|
|
150
|
+
|
|
151
|
+
async function handle(event: NotifyEvent, message: string) {
|
|
152
|
+
config = loadConfig(cwd);
|
|
153
|
+
const detail = event === "agent_settled" && config.includeInput && lastInput ? `: ${lastInput}` : "";
|
|
154
|
+
const payload: NotifyPayload = {
|
|
155
|
+
event,
|
|
156
|
+
title: config.title,
|
|
157
|
+
message: `${message}${detail}`,
|
|
158
|
+
timestamp: Date.now(),
|
|
159
|
+
cwd,
|
|
160
|
+
pid: process.pid,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (!(await shouldSkip(config))) {
|
|
164
|
+
await sendNotification(config, payload);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await runScript(config, payload);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.warn(`notify script failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
pi.on("session_start", async (_event: unknown, ctx: { cwd: string }) => {
|
|
175
|
+
cwd = ctx.cwd;
|
|
176
|
+
config = loadConfig(cwd);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
pi.on("input", (event: { source?: string; text?: string }) => {
|
|
180
|
+
if (event.source === "extension") return { action: "continue" };
|
|
181
|
+
if (typeof event.text === "string") {
|
|
182
|
+
config = loadConfig(cwd);
|
|
183
|
+
lastInput = summarizeInput(event.text, config.maxInputLength);
|
|
184
|
+
}
|
|
185
|
+
return { action: "continue" };
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
pi.on("agent_settled", async () => {
|
|
189
|
+
await handle("agent_settled", config.settledMessage);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
pi.events.on("permissions:ask", (event: unknown) => {
|
|
193
|
+
if (!config.permissionAsk) return;
|
|
194
|
+
const tool = permissionToolName(event);
|
|
195
|
+
void handle("permission_ask", config.permissionMessage.replaceAll("{tool}", tool));
|
|
196
|
+
});
|
|
197
|
+
}
|