Skip to content

Commit 76d590c

Browse files
author
mgabor3141
committed
feat: add pi-after-hours extension package
Message budget for quiet hours — break the late-night engagement loop. During quiet hours (default 23:00–07:00), the user gets a configurable number of messages. After the budget is spent, pi's UI is blocked with a full-screen terminal takeover until quiet hours end. - Valibot config schema with strict time validation (HH:MM, 00:00–23:59) - Message counter persisted in /tmp (ephemeral across reboots) - messageLimit supports 0 for immediate block on quiet hours - 23 tests covering config validation, time logic, and counter persistence docs: add pi-after-hours to README
1 parent 72b0e72 commit 76d590c

12 files changed

Lines changed: 684 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Monorepo of extensions and libraries for [pi](https://pi.dev). Managed with Yarn
2020
| pi-bash-trim | `packages/bash-trim/` | pi extension |
2121
| pi-desktop-notify | `packages/desktop-notify/` | pi extension + library |
2222
| pi-budget-model | `packages/budget-model/` | library |
23+
| pi-after-hours | `packages/after-hours/` | pi extension |
2324

2425
## Workflow
2526

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Utilities for running [pi](https://pi.dev) agents with less babysitting: auto-re
99
Install the extensions together, or pick only the ones you want. Defaults are tuned for good behavior out of the box.
1010

1111
```bash
12-
pi install pi-safeguard pi-bash-trim pi-desktop-notify
12+
pi install pi-safeguard pi-bash-trim pi-desktop-notify pi-after-hours
1313
```
1414

1515
### [pi-safeguard](packages/safeguard/)
@@ -26,6 +26,10 @@ Smart bash output trimming. Intercepts tool results before they enter the contex
2626

2727
Desktop notifications with terminal focus tracking. Notifications are suppressed while the terminal is in the foreground and only fire when you've tabbed away — so you hear about finished tasks without being interrupted mid-thought. Click-to-focus brings the terminal back. Works on macOS (terminal-notifier) and Linux (notify-send), with compositor support for niri, sway, and hyprland.
2828

29+
### [pi-after-hours](packages/after-hours/)
30+
31+
Message budget for quiet hours. During configurable quiet hours (default 23:00–07:00), you get a limited number of messages (default 3). After the budget is spent, pi's UI is replaced with a full-screen block until quiet hours end — the agent keeps working on your last request, you check results in the morning. Counter resets daily and doesn't survive reboots; the goal is breaking the autopilot loop, not hard enforcement.
32+
2933
## Libraries
3034

3135
### [pi-budget-model](packages/budget-model/)

packages/after-hours/README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# pi-after-hours
2+
3+
> From [yapp](https://github.com/mgabor3141/yapp) · yet another pi pack
4+
5+
Break the late-night engagement loop.
6+
7+
During quiet hours (default 23:00–07:00), you get a configurable number of messages (default 3). After the budget is spent, pi's UI is replaced with a full-screen block until quiet hours end. The agent continues processing your last message normally — check results in the morning.
8+
9+
## Install
10+
11+
```bash
12+
pi install npm:pi-after-hours
13+
```
14+
15+
## Configuration
16+
17+
Place `pi-after-hours.json` in `~/.pi/agent/extensions/`:
18+
19+
```json
20+
{
21+
"enabled": true,
22+
"quietHoursStart": "23:00",
23+
"quietHoursEnd": "07:00",
24+
"messageLimit": 3,
25+
"warningTime": "23:30",
26+
"blockMessage": "The agent is working. You can rest now and check results in the morning."
27+
}
28+
```
29+
30+
All fields are optional — defaults are shown above.
31+
32+
| Field | Type | Description |
33+
|-------|------|-------------|
34+
| `enabled` | boolean | Enable/disable the extension |
35+
| `quietHoursStart` | `"HH:MM"` | When quiet hours begin (local time) |
36+
| `quietHoursEnd` | `"HH:MM"` | When quiet hours end (local time) |
37+
| `messageLimit` | number (≥0) | Messages allowed during quiet hours (0 = block immediately) |
38+
| `warningTime` | `"HH:MM"` | When to show the countdown widget |
39+
| `blockMessage` | string | Text shown on the block screen |
40+
41+
Quiet hours crossing midnight (e.g. 23:00–07:00) are handled correctly.
42+
43+
## Behavior
44+
45+
**Before quiet hours:** Normal operation, no restrictions.
46+
47+
**During quiet hours, before warning time:** Normal operation, no UI changes.
48+
49+
**After warning time (or first message sent):** Widget shows above the editor:
50+
> 🌙 Quiet hours active. 3 messages remaining tonight.
51+
52+
**After final message:** Full-screen block with centered box. Ctrl+C/Ctrl+D to exit pi. The block auto-dismisses when quiet hours end.
53+
54+
**Counter persistence:** Message count is stored in `/tmp/pi-after-hours-{date}.json` and resets daily. A reboot also resets it.
55+
56+
## Commands
57+
58+
- `/after-hours` — Show current status (quiet hours active, budget remaining)

packages/after-hours/package.json

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{
2+
"name": "pi-after-hours",
3+
"version": "0.1.0",
4+
"description": "Message budget for quiet hours — break the late-night engagement loop",
5+
"author": "mgabor3141",
6+
"license": "MIT",
7+
"repository": {
8+
"url": "git+https://github.com/mgabor3141/yapp.git",
9+
"directory": "packages/after-hours"
10+
},
11+
"keywords": [
12+
"pi-package",
13+
"wellbeing",
14+
"quiet-hours",
15+
"message-limit"
16+
],
17+
"type": "module",
18+
"main": "dist/index.js",
19+
"types": "dist/index.d.ts",
20+
"exports": {
21+
".": {
22+
"import": "./dist/index.js",
23+
"types": "./dist/index.d.ts"
24+
}
25+
},
26+
"files": [
27+
"dist",
28+
"README.md"
29+
],
30+
"scripts": {
31+
"build": "tsup"
32+
},
33+
"pi": {
34+
"extensions": [
35+
"dist/index.js"
36+
]
37+
},
38+
"dependencies": {
39+
"valibot": "^1.2.0"
40+
},
41+
"peerDependencies": {
42+
"@mariozechner/pi-coding-agent": "*",
43+
"@mariozechner/pi-tui": "*"
44+
},
45+
"devDependencies": {
46+
"@mariozechner/pi-coding-agent": "^0.57.0",
47+
"@mariozechner/pi-tui": "^0.57.0",
48+
"@types/node": "^25.3.5",
49+
"tsup": "^8.5.1",
50+
"typescript": "^5.9.3"
51+
}
52+
}

packages/after-hours/src/config.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import * as v from "valibot";
4+
5+
// --- Schema ---
6+
7+
const TimeString = v.pipe(
8+
v.string(),
9+
v.regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, 'Must be a valid "HH:MM" time (00:00–23:59)'),
10+
);
11+
12+
export const AfterHoursConfig = v.object({
13+
enabled: v.optional(v.boolean(), true),
14+
quietHoursStart: v.optional(TimeString, "23:00"),
15+
quietHoursEnd: v.optional(TimeString, "07:00"),
16+
messageLimit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0)), 3),
17+
warningTime: v.optional(TimeString, "23:30"),
18+
blockMessage: v.optional(v.string(), "The agent is working. You can rest now and check results in the morning."),
19+
});
20+
export type AfterHoursConfig = v.InferOutput<typeof AfterHoursConfig>;
21+
22+
// --- Config loading ---
23+
24+
export function configPath(): string {
25+
return join(process.env.HOME ?? "~", ".pi", "agent", "extensions", "pi-after-hours.json");
26+
}
27+
28+
export function loadConfig(path = configPath()): AfterHoursConfig {
29+
let raw: unknown = {};
30+
try {
31+
raw = JSON.parse(readFileSync(path, "utf-8"));
32+
} catch (err) {
33+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
34+
throw new Error(`after-hours: failed to read config at ${path}: ${err instanceof Error ? err.message : err}`);
35+
}
36+
}
37+
38+
try {
39+
return v.parse(AfterHoursConfig, raw);
40+
} catch (err) {
41+
throw new Error(`after-hours: invalid config at ${path}: ${err instanceof Error ? err.message : err}`);
42+
}
43+
}
44+
45+
// --- Time helpers ---
46+
47+
export function toMinutes(timeStr: string): number {
48+
const [h, m] = timeStr.split(":").map(Number);
49+
return h! * 60 + m!;
50+
}
51+
52+
/** Check if a time (in minutes since midnight) falls within quiet hours. */
53+
export function isInQuietHours(now: number, start: number, end: number): boolean {
54+
return start > end ? now >= start || now < end : now >= start && now < end;
55+
}
56+
57+
/** Check if a time is past the warning threshold within quiet hours. */
58+
export function isPastWarningTime(now: number, warn: number, end: number, inQuiet: boolean): boolean {
59+
if (!inQuiet) return false;
60+
if (now < end && warn > end) return true;
61+
return now >= warn;
62+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
4+
export interface CounterState {
5+
date: string; // YYYY-MM-DD
6+
messagesUsed: number;
7+
}
8+
9+
export function todayStr(now = new Date()): string {
10+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
11+
}
12+
13+
export function stateFilePath(date = todayStr()): string {
14+
return join("/tmp", `pi-after-hours-${date}.json`);
15+
}
16+
17+
export function loadCounter(today = todayStr()): CounterState {
18+
try {
19+
const state: CounterState = JSON.parse(readFileSync(stateFilePath(today), "utf-8"));
20+
if (state.date === today) return state;
21+
} catch {}
22+
return { date: today, messagesUsed: 0 };
23+
}
24+
25+
export function saveCounter(state: CounterState): void {
26+
try {
27+
writeFileSync(stateFilePath(state.date), JSON.stringify(state));
28+
} catch {}
29+
}

0 commit comments

Comments
 (0)