@diegopetrucci/pi-extensions 0.1.32 → 0.1.33
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 +1 -0
- package/extensions/brrr/README.md +84 -0
- package/extensions/brrr/brrr.example.json +12 -0
- package/extensions/brrr/index.ts +250 -0
- package/extensions/brrr/package.json +28 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
|
|
|
5
5
|
**License note:** This root collection package is mixed-licensed: most of it is MIT, while [`extensions/review`](./extensions/review) is Apache-2.0 and includes its own [LICENSE](./extensions/review/LICENSE).
|
|
6
6
|
|
|
7
7
|
- [`agent-workflow-audit`](./extensions/agent-workflow-audit): Adds `/agent-workflow-audit`, which runs an isolated repo workflow audit subagent and returns only the final distilled report to the main session.
|
|
8
|
+
- [`brrr`](./extensions/brrr): Sends brrr push notifications when pi finishes an agent turn and is ready for input, with optional macOS idle gating.
|
|
8
9
|
- [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
|
|
9
10
|
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
|
|
10
11
|
- [`context-inspector`](./extensions/context-inspector): Adds `/context`, a local self-contained HTML dashboard that breaks down where the current session context is going, with category overview, top offenders, and drilldown search.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# brrr
|
|
2
|
+
|
|
3
|
+
A pi extension that sends [brrr](https://brrr.now) push notifications when pi finishes an agent turn and is ready for input.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
### Standalone npm package
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@diegopetrucci/pi-brrr
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Collection package
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi install npm:@diegopetrucci/pi-extensions
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### GitHub package
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install git:github.com/diegopetrucci/pi-extensions
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then reload pi:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
/reload
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Config files are merged, with project config overriding global config:
|
|
34
|
+
|
|
35
|
+
- `~/.pi/agent/extensions/brrr.json`
|
|
36
|
+
- `<project>/.pi/brrr.json`
|
|
37
|
+
|
|
38
|
+
The default config expects your webhook in `BRRR_WEBHOOK_URL`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export BRRR_WEBHOOK_URL='https://api.brrr.now/v1/br_...'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A ready-to-copy sample file is included at [`brrr.example.json`](./brrr.example.json).
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"enabled": true,
|
|
51
|
+
"onlyWhenInteractive": true,
|
|
52
|
+
"webhook": "$BRRR_WEBHOOK_URL",
|
|
53
|
+
"idleSeconds": 20,
|
|
54
|
+
"title": "Pi finished",
|
|
55
|
+
"message": "Pi finished working in '{project}'.",
|
|
56
|
+
"includeLastAssistantMessage": true,
|
|
57
|
+
"sound": "",
|
|
58
|
+
"openUrl": "",
|
|
59
|
+
"imageUrl": ""
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
|
|
65
|
+
- `/brrr` shows whether notifications are enabled, whether the webhook resolves, and the idle threshold.
|
|
66
|
+
|
|
67
|
+
## Config fields
|
|
68
|
+
|
|
69
|
+
- `enabled`: master on/off switch
|
|
70
|
+
- `onlyWhenInteractive`: skip notifications in print / non-UI mode
|
|
71
|
+
- `webhook`: brrr webhook URL or an environment reference like `$BRRR_WEBHOOK_URL`
|
|
72
|
+
- `idleSeconds`: only send when macOS has been idle for at least this many seconds; set to `null` to disable idle gating
|
|
73
|
+
- `title`: notification title; supports `{project}` and `{cwd}`
|
|
74
|
+
- `message`: fallback notification body; supports `{project}` and `{cwd}`
|
|
75
|
+
- `includeLastAssistantMessage`: use the final assistant message as the notification body when available
|
|
76
|
+
- `sound`: optional brrr sound value
|
|
77
|
+
- `openUrl`: optional URL to open from the notification
|
|
78
|
+
- `imageUrl`: optional image URL
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
|
|
82
|
+
- Hooks the `agent_end` event.
|
|
83
|
+
- The extension sends directly to the brrr webhook; it does not require the `brrr` CLI at runtime.
|
|
84
|
+
- By default, notifications are skipped unless the Mac has been idle for at least 20 seconds.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"onlyWhenInteractive": true,
|
|
4
|
+
"webhook": "$BRRR_WEBHOOK_URL",
|
|
5
|
+
"idleSeconds": 20,
|
|
6
|
+
"title": "Pi finished",
|
|
7
|
+
"message": "Pi finished working in '{project}'.",
|
|
8
|
+
"includeLastAssistantMessage": true,
|
|
9
|
+
"sound": "",
|
|
10
|
+
"openUrl": "",
|
|
11
|
+
"imageUrl": ""
|
|
12
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi brrr Extension
|
|
3
|
+
*
|
|
4
|
+
* Sends a brrr push notification when pi finishes an agent turn and is ready
|
|
5
|
+
* for more input.
|
|
6
|
+
*
|
|
7
|
+
* Config files (project overrides global):
|
|
8
|
+
* - ~/.pi/agent/extensions/brrr.json
|
|
9
|
+
* - <cwd>/.pi/brrr.json
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { basename, join } from "node:path";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const execFileAsync = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
interface BrrrConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
onlyWhenInteractive: boolean;
|
|
23
|
+
webhook: string;
|
|
24
|
+
idleSeconds: number | null;
|
|
25
|
+
title: string;
|
|
26
|
+
message: string;
|
|
27
|
+
includeLastAssistantMessage: boolean;
|
|
28
|
+
sound: string;
|
|
29
|
+
openUrl: string;
|
|
30
|
+
imageUrl: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface BrrrPayload {
|
|
34
|
+
message: string;
|
|
35
|
+
title?: string;
|
|
36
|
+
subtitle?: string;
|
|
37
|
+
expiration_date?: string;
|
|
38
|
+
sound?: string;
|
|
39
|
+
open_url?: string;
|
|
40
|
+
image_url?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_CONFIG: BrrrConfig = {
|
|
44
|
+
enabled: true,
|
|
45
|
+
onlyWhenInteractive: true,
|
|
46
|
+
webhook: "$BRRR_WEBHOOK_URL",
|
|
47
|
+
idleSeconds: 20,
|
|
48
|
+
title: "Pi finished",
|
|
49
|
+
message: "Pi finished working in '{project}'.",
|
|
50
|
+
includeLastAssistantMessage: true,
|
|
51
|
+
sound: "",
|
|
52
|
+
openUrl: "",
|
|
53
|
+
imageUrl: "",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function readConfigFile(path: string): Partial<BrrrConfig> {
|
|
57
|
+
if (!existsSync(path)) return {};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path, "utf-8")) as Partial<BrrrConfig>;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error(`Warning: Could not parse ${path}: ${error}`);
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mergeConfig(base: BrrrConfig, overrides: Partial<BrrrConfig>): BrrrConfig {
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadConfig(cwd: string): BrrrConfig {
|
|
75
|
+
const globalConfig = readConfigFile(join(getAgentDir(), "extensions", "brrr.json"));
|
|
76
|
+
const projectConfig = readConfigFile(join(cwd, ".pi", "brrr.json"));
|
|
77
|
+
return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveWebhook(raw: string): string | undefined {
|
|
81
|
+
const value = raw.trim();
|
|
82
|
+
if (!value) return undefined;
|
|
83
|
+
|
|
84
|
+
const envMatch = value.match(/^\$([A-Z0-9_]+)$/) ?? value.match(/^\$\{([A-Z0-9_]+)\}$/);
|
|
85
|
+
if (envMatch) return process.env[envMatch[1]]?.trim() || undefined;
|
|
86
|
+
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isBrrrWebhookUrl(value: string): boolean {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(value);
|
|
93
|
+
return (
|
|
94
|
+
url.protocol === "https:" &&
|
|
95
|
+
(url.hostname === "api.brrr.now" || url.hostname === "dev.api.brrr.now") &&
|
|
96
|
+
/^\/v1\/br_[A-Za-z0-9_]+$/.test(url.pathname)
|
|
97
|
+
);
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function getMacOsIdleSeconds(): Promise<number | null> {
|
|
104
|
+
if (process.platform !== "darwin") return null;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync("ioreg", ["-c", "IOHIDSystem"], {
|
|
108
|
+
timeout: 1000,
|
|
109
|
+
maxBuffer: 1024 * 1024,
|
|
110
|
+
});
|
|
111
|
+
const match = stdout.match(/HIDIdleTime"\s*=\s*(\d+)/) ?? stdout.match(/HIDIdleTime\s+=\s+(\d+)/);
|
|
112
|
+
if (!match) return null;
|
|
113
|
+
|
|
114
|
+
const idleNanoseconds = Number(match[1]);
|
|
115
|
+
if (!Number.isFinite(idleNanoseconds)) return null;
|
|
116
|
+
|
|
117
|
+
return Math.floor(idleNanoseconds / 1_000_000_000);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function shouldSkipForIdleThreshold(idleSeconds: number | null): Promise<boolean> {
|
|
124
|
+
if (idleSeconds === null) return false;
|
|
125
|
+
if (!Number.isFinite(idleSeconds) || idleSeconds < 0) return false;
|
|
126
|
+
|
|
127
|
+
const currentIdleSeconds = await getMacOsIdleSeconds();
|
|
128
|
+
if (currentIdleSeconds === null) return false;
|
|
129
|
+
|
|
130
|
+
return currentIdleSeconds < idleSeconds;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatTemplate(template: string, cwd: string): string {
|
|
134
|
+
const project = basename(cwd || process.cwd());
|
|
135
|
+
return template.replaceAll("{project}", project).replaceAll("{cwd}", cwd);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractTextContent(content: unknown): string {
|
|
139
|
+
if (typeof content === "string") return content;
|
|
140
|
+
if (!Array.isArray(content)) return "";
|
|
141
|
+
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
for (const part of content) {
|
|
144
|
+
if (typeof part === "string") {
|
|
145
|
+
parts.push(part);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (typeof part !== "object" || part === null) continue;
|
|
149
|
+
|
|
150
|
+
const maybeText = (part as { text?: unknown }).text;
|
|
151
|
+
if (typeof maybeText === "string") parts.push(maybeText);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return parts.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function lastAssistantMessage(messages: readonly unknown[]): string | undefined {
|
|
158
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
159
|
+
const message = messages[index];
|
|
160
|
+
if (typeof message !== "object" || message === null) continue;
|
|
161
|
+
if ((message as { role?: unknown }).role !== "assistant") continue;
|
|
162
|
+
|
|
163
|
+
const text = extractTextContent((message as { content?: unknown }).content).trim();
|
|
164
|
+
if (text) return text;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function truncateMessage(value: string): string {
|
|
171
|
+
const trimmed = value.trim();
|
|
172
|
+
if (trimmed.length <= 800) return trimmed;
|
|
173
|
+
return `${trimmed.slice(0, 797).trimEnd()}...`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function sendBrrr(webhook: string, payload: BrrrPayload): Promise<{ ok: boolean; error?: string }> {
|
|
177
|
+
const controller = new AbortController();
|
|
178
|
+
const timeout = setTimeout(() => controller.abort(), 2_000);
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const body: Record<string, string> = { message: payload.message };
|
|
182
|
+
if (payload.title) body.title = payload.title;
|
|
183
|
+
if (payload.subtitle) body.subtitle = payload.subtitle;
|
|
184
|
+
if (payload.expiration_date) body.expiration_date = payload.expiration_date;
|
|
185
|
+
if (payload.sound) body.sound = payload.sound;
|
|
186
|
+
if (payload.open_url) body.open_url = payload.open_url;
|
|
187
|
+
if (payload.image_url) body.image_url = payload.image_url;
|
|
188
|
+
|
|
189
|
+
const response = await fetch(webhook, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: {
|
|
192
|
+
"content-type": "application/json",
|
|
193
|
+
accept: "application/json",
|
|
194
|
+
},
|
|
195
|
+
body: JSON.stringify(body),
|
|
196
|
+
signal: controller.signal,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (response.status === 202) return { ok: true };
|
|
200
|
+
return { ok: false, error: `Unexpected response status ${response.status}.` };
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return { ok: false, error: error instanceof Error ? error.message : "Unknown webhook failure." };
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timeout);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function notify(ctx: ExtensionCommandContext, message: string, type: "info" | "warning" | "error" = "info"): void {
|
|
209
|
+
if (ctx.hasUI) ctx.ui.notify(message, type);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function describeConfig(config: BrrrConfig, webhook: string | undefined): string {
|
|
213
|
+
const webhookStatus = webhook ? (isBrrrWebhookUrl(webhook) ? "configured" : "invalid") : "missing";
|
|
214
|
+
const idle = config.idleSeconds === null ? "off" : `${config.idleSeconds}s`;
|
|
215
|
+
return `brrr is ${config.enabled ? "enabled" : "disabled"}; webhook ${webhookStatus}; idle threshold ${idle}.`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export default function brrrExtension(pi: ExtensionAPI) {
|
|
219
|
+
pi.registerCommand("brrr", {
|
|
220
|
+
description: "Show brrr notification status",
|
|
221
|
+
handler: async (_args, ctx) => {
|
|
222
|
+
const config = loadConfig(ctx.cwd);
|
|
223
|
+
notify(ctx, describeConfig(config, resolveWebhook(config.webhook)));
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
228
|
+
const config = loadConfig(ctx.cwd);
|
|
229
|
+
if (!config.enabled) return;
|
|
230
|
+
if (config.onlyWhenInteractive && !ctx.hasUI) return;
|
|
231
|
+
|
|
232
|
+
const webhook = resolveWebhook(config.webhook);
|
|
233
|
+
if (!webhook || !isBrrrWebhookUrl(webhook)) return;
|
|
234
|
+
if (await shouldSkipForIdleThreshold(config.idleSeconds)) return;
|
|
235
|
+
|
|
236
|
+
const assistantMessage = config.includeLastAssistantMessage ? lastAssistantMessage(event.messages as readonly unknown[]) : undefined;
|
|
237
|
+
const message = truncateMessage(assistantMessage || formatTemplate(config.message, ctx.cwd));
|
|
238
|
+
const result = await sendBrrr(webhook, {
|
|
239
|
+
title: formatTemplate(config.title, ctx.cwd),
|
|
240
|
+
message,
|
|
241
|
+
sound: config.sound.trim() || undefined,
|
|
242
|
+
open_url: config.openUrl.trim() || undefined,
|
|
243
|
+
image_url: config.imageUrl.trim() || undefined,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!result.ok && result.error) {
|
|
247
|
+
console.error(`brrr notification failed: ${result.error}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-brrr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi extension that sends brrr push notifications when pi is ready for input.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "brrr", "notification", "push"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/brrr"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.ts",
|
|
14
|
+
"README.md",
|
|
15
|
+
"brrr.example.json"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A collection of pi extensions for context management, workflow audits, review-comment triage, notifications, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, and model/provider helpers.",
|
|
3
|
+
"version": "0.1.33",
|
|
4
|
+
"description": "A collection of pi extensions for context management, workflow audits, review-comment triage, notifications, brrr push alerts, safety guards, GitHub research, repo-local knowledge, todos, tool rendering, and model/provider helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"pi": {
|
|
35
35
|
"extensions": [
|
|
36
36
|
"./extensions/agent-workflow-audit/index.ts",
|
|
37
|
+
"./extensions/brrr/index.ts",
|
|
37
38
|
"./extensions/minimal-footer/index.ts",
|
|
38
39
|
"./extensions/oracle/index.ts",
|
|
39
40
|
"./extensions/context-cap/index.ts",
|