@cad0p/pi-heartbeat 0.0.1-20260814.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/.well-known/skills/index.json +9 -0
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/package.json +65 -0
- package/skills/pi-heartbeat/SKILL.md +75 -0
- package/src/commands/cancel.ts +50 -0
- package/src/guards/prompt-injection.ts +31 -0
- package/src/guards/sleep-interceptor.ts +80 -0
- package/src/index.ts +37 -0
- package/src/timer-manager.ts +165 -0
- package/src/tools/heartbeat.ts +100 -0
- package/src/tools/timer.ts +54 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skills": [
|
|
3
|
+
{
|
|
4
|
+
"name": "pi-heartbeat",
|
|
5
|
+
"path": "skills/pi-heartbeat/SKILL.md",
|
|
6
|
+
"description": "Non-blocking timers and heartbeats for pi agents. Use instead of `sleep` when waiting for builds, deployments, CI pipelines, or any async process. The agent returns to idle immediately and gets woken up when the timer fires. Triggers: timer, heartbeat, wait for build, poll, cron, wake me up, non-blocking wait, check back later, monitor deployment, periodic check."
|
|
7
|
+
}
|
|
8
|
+
]
|
|
9
|
+
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marc Fargas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# @cad0p/pi-heartbeat
|
|
2
|
+
|
|
3
|
+
Non-blocking timers and heartbeats for [pi](https://github.com/badlogic/pi-mono) agents.
|
|
4
|
+
|
|
5
|
+
`pi-heartbeat` replaces blocking `sleep` waits with timers that return immediately, so the agent can pause work without freezing the session.
|
|
6
|
+
|
|
7
|
+
## What is pi?
|
|
8
|
+
|
|
9
|
+
[Pi](https://github.com/badlogic/pi-mono) is an AI coding agent CLI (`@earendil-works/pi-coding-agent`).
|
|
10
|
+
|
|
11
|
+
You run AI models in an interactive terminal, and the agent executes tools/commands while you chat with it. In that environment, `bash` calls like `sleep 60` block the active turn and prevent interaction until the command finishes.
|
|
12
|
+
|
|
13
|
+
This extension gives the agent non-blocking waiting primitives (`timer`, `heartbeat`) so it can schedule follow-ups and return to idle immediately.
|
|
14
|
+
|
|
15
|
+
## Why this extension?
|
|
16
|
+
|
|
17
|
+
Without this extension, waits are usually implemented with shell `sleep`, polling loops, or long-running turns. That causes:
|
|
18
|
+
|
|
19
|
+
- blocked chat while waiting
|
|
20
|
+
- poor UX for long builds/deploys
|
|
21
|
+
- brittle polling logic in bash
|
|
22
|
+
|
|
23
|
+
With `pi-heartbeat`:
|
|
24
|
+
|
|
25
|
+
- waits are non-blocking
|
|
26
|
+
- timers can be canceled
|
|
27
|
+
- follow-up messages include explicit context about what to check next
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install npm:@cad0p/pi-heartbeat
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or add to your `settings.json`:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"packages": ["npm:@cad0p/pi-heartbeat"]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Prerequisites
|
|
44
|
+
|
|
45
|
+
- [pi](https://github.com/badlogic/pi-mono) installed and working
|
|
46
|
+
- A pi session where the agent can call tools
|
|
47
|
+
- Extension installed via `pi install` or `settings.json`
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
### `timer`
|
|
52
|
+
|
|
53
|
+
One-shot wake-up after `N` seconds.
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
timer(seconds: 60, message: "Check if build #42 finished — run gh run view 42")
|
|
57
|
+
timer(seconds: 120, message: "Retry deploy check", id: "deploy-check-2")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- returns immediately (non-blocking)
|
|
61
|
+
- wakes the agent once when the timer expires
|
|
62
|
+
- supports multiple simultaneous timers
|
|
63
|
+
- optional `id` for named cancellation
|
|
64
|
+
|
|
65
|
+
### `heartbeat`
|
|
66
|
+
|
|
67
|
+
Periodic wake-up every `N` seconds until stopped.
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
heartbeat(action: "start", interval_seconds: 30, message: "Check deploy status")
|
|
71
|
+
heartbeat(action: "status")
|
|
72
|
+
heartbeat(action: "stop")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- wakes the agent repeatedly with the provided message
|
|
76
|
+
- only one heartbeat can be active at a time
|
|
77
|
+
- starting a new heartbeat replaces the previous one
|
|
78
|
+
|
|
79
|
+
### `/cancel-timer`
|
|
80
|
+
|
|
81
|
+
Manual timer/heartbeat control from chat.
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/cancel-timer # list active timers
|
|
85
|
+
/cancel-timer <id> # cancel specific timer
|
|
86
|
+
/cancel-timer heartbeat # stop heartbeat
|
|
87
|
+
/cancel-timer all # cancel all timers + heartbeat
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use this when you need immediate control without waiting for the next trigger.
|
|
91
|
+
|
|
92
|
+
## When to Use
|
|
93
|
+
|
|
94
|
+
| Scenario | Before (blocks) | After (non-blocking) |
|
|
95
|
+
|----------|------------------|----------------------|
|
|
96
|
+
| Wait for build | `sleep 60 && gh run view 42` | `timer(60, 'Check build #42')` |
|
|
97
|
+
| Monitor deploy | `while true; do sleep 30; check; done` | `heartbeat(start, 30, 'Check deploy')` |
|
|
98
|
+
| Rate-limited API | `sleep 10` between calls | `timer(10, 'Resume API calls')` |
|
|
99
|
+
|
|
100
|
+
## How It Works
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
User: "Start the build and let me know when it's done"
|
|
104
|
+
|
|
105
|
+
Agent: calls timer(seconds: 60, message: "Check build status")
|
|
106
|
+
-> tool returns immediately
|
|
107
|
+
-> agent tells user "I'll check in 60s"
|
|
108
|
+
-> session stays interactive
|
|
109
|
+
|
|
110
|
+
[60 seconds later]
|
|
111
|
+
|
|
112
|
+
Timer fires -> pi triggers a new agent turn
|
|
113
|
+
-> agent wakes with the timer message in context
|
|
114
|
+
-> agent runs the follow-up check
|
|
115
|
+
-> if needed, schedules another timer/heartbeat
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## vs. sleep
|
|
119
|
+
|
|
120
|
+
| | `sleep 60` | `timer(60)` |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| Chat blocked? | ✅ Yes | ❌ No |
|
|
123
|
+
| User can interact? | ❌ No | ✅ Yes |
|
|
124
|
+
| Cancellable? | Kill process | `/cancel-timer` |
|
|
125
|
+
| Multiple waits? | Sequential | Concurrent |
|
|
126
|
+
| Wake-up context? | Shell state only | Explicit message/context |
|
|
127
|
+
|
|
128
|
+
## Limits
|
|
129
|
+
|
|
130
|
+
- `timer`
|
|
131
|
+
- range: **1–3600 seconds**
|
|
132
|
+
- supports multiple simultaneous timers
|
|
133
|
+
- optional named `id` for cancellation
|
|
134
|
+
- `heartbeat`
|
|
135
|
+
- interval: **10–3600 seconds**
|
|
136
|
+
- only one active heartbeat at a time
|
|
137
|
+
|
|
138
|
+
## Sleep Interceptor
|
|
139
|
+
|
|
140
|
+
This package also intercepts `sleep` in bash and blocks it only when `sleep` is the **only or last command** in a call.
|
|
141
|
+
|
|
142
|
+
- `sleep 60` → **blocked** (only command)
|
|
143
|
+
- `npm run build && sleep 60` → **blocked** (`sleep` is last)
|
|
144
|
+
- `sleep 5 && npm start` → **allowed** (`sleep` is a startup delay before real work)
|
|
145
|
+
|
|
146
|
+
Disable interception with:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
PI_SLEEP_INTERCEPTOR=0
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Best Practices
|
|
153
|
+
|
|
154
|
+
- Put actionable context in timer messages (what to check, what command to run)
|
|
155
|
+
- Choose reasonable intervals (don’t poll every 10s for long operations)
|
|
156
|
+
- Stop heartbeats when done (they continue until explicitly stopped)
|
|
157
|
+
- Use chained timers for progressive backoff: `30s -> 60s -> 120s`
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm test # vitest run
|
|
163
|
+
npm run typecheck # tsc --noEmit
|
|
164
|
+
npm run lint # eslint
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cad0p/pi-heartbeat",
|
|
3
|
+
"version": "0.0.1-20260814.0",
|
|
4
|
+
"description": "Non-blocking timers and heartbeats for pi agents — stop using sleep",
|
|
5
|
+
"author": "Marc Fargas",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/cad0p/pi-heartbeat.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi",
|
|
17
|
+
"pi-package",
|
|
18
|
+
"agent",
|
|
19
|
+
"timer",
|
|
20
|
+
"heartbeat",
|
|
21
|
+
"cron",
|
|
22
|
+
"non-blocking",
|
|
23
|
+
"polling"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"src/**/*.ts",
|
|
27
|
+
"skills/**/*.md",
|
|
28
|
+
".well-known/**",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./src/index.ts"
|
|
35
|
+
],
|
|
36
|
+
"skills": [
|
|
37
|
+
"./skills"
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": ">=0.84.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "eslint src/ __tests__/",
|
|
48
|
+
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@eslint/js": "^9.27.0",
|
|
52
|
+
"@sinclair/typebox": "^0.34.0",
|
|
53
|
+
"eslint": "^9.27.0",
|
|
54
|
+
"globals": "^16.1.0",
|
|
55
|
+
"typescript": "^5.8.3",
|
|
56
|
+
"typescript-eslint": "^8.32.1",
|
|
57
|
+
"vitest": "^3.1.4",
|
|
58
|
+
"@changesets/cli": "^2.29.3",
|
|
59
|
+
"@marcfargas/pi-test-harness": "^0.4.1",
|
|
60
|
+
"@earendil-works/pi-coding-agent": "^0.84.0"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=22.19.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-heartbeat
|
|
3
|
+
description: >-
|
|
4
|
+
Non-blocking timers and heartbeats for pi agents. Use instead of `sleep` when waiting
|
|
5
|
+
for builds, deployments, CI pipelines, or any async process. The agent returns to idle
|
|
6
|
+
immediately and gets woken up when the timer fires.
|
|
7
|
+
Triggers: timer, heartbeat, wait for build, poll, cron, wake me up, non-blocking wait,
|
|
8
|
+
check back later, monitor deployment, periodic check.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Heartbeat & Timer Tools
|
|
12
|
+
|
|
13
|
+
2 tools for non-blocking waiting. Stop using `sleep` — it blocks the chat.
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
| Tool | Purpose |
|
|
18
|
+
|------|---------|
|
|
19
|
+
| `timer` | One-shot "wake me up in N seconds" with a context message |
|
|
20
|
+
| `heartbeat` | Periodic "ping me every N seconds" (start/stop/status) |
|
|
21
|
+
|
|
22
|
+
## When to Use
|
|
23
|
+
|
|
24
|
+
| Scenario | Before (blocks chat) | After (non-blocking) |
|
|
25
|
+
|----------|---------------------|---------------------|
|
|
26
|
+
| Wait for build | `sleep 60 && gh run view 42` | `timer(60, "Check build #42")` |
|
|
27
|
+
| Monitor deploy | `while true; do sleep 30; check; done` | `heartbeat(start, 30, "Check deploy")` |
|
|
28
|
+
| Rate-limited API | `sleep 10` between calls | `timer(10, "Resume API calls")` |
|
|
29
|
+
|
|
30
|
+
## How It Works
|
|
31
|
+
|
|
32
|
+
1. Agent calls `timer` or `heartbeat` — tool returns **immediately**
|
|
33
|
+
2. Agent finishes response, goes idle — **user can chat freely**
|
|
34
|
+
3. When timer fires, a message is injected via `sendMessage({ triggerTurn: true })`
|
|
35
|
+
4. Agent wakes up with full context of what it was waiting for
|
|
36
|
+
5. Agent acts (check status, retry, etc.) — can set another timer if needed
|
|
37
|
+
|
|
38
|
+
## Timer
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
timer(seconds: 60, message: "Check if build #42 finished — run gh run view 42")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
- Returns immediately with timer ID
|
|
45
|
+
- Multiple timers can be active simultaneously
|
|
46
|
+
- Optional `id` parameter for named timers (enables cancellation)
|
|
47
|
+
- Max 3600 seconds (1 hour)
|
|
48
|
+
|
|
49
|
+
## Heartbeat
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
heartbeat(action: "start", interval_seconds: 30, message: "Check deploy status")
|
|
53
|
+
heartbeat(action: "status")
|
|
54
|
+
heartbeat(action: "stop")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Only one heartbeat active at a time (starting a new one stops the previous)
|
|
58
|
+
- Min interval: 10 seconds
|
|
59
|
+
- Tracks tick count for monitoring duration
|
|
60
|
+
|
|
61
|
+
## Commands
|
|
62
|
+
|
|
63
|
+
| Command | Description |
|
|
64
|
+
|---------|-------------|
|
|
65
|
+
| `/cancel-timer` | List active timers and heartbeat |
|
|
66
|
+
| `/cancel-timer <id>` | Cancel a specific timer |
|
|
67
|
+
| `/cancel-timer heartbeat` | Stop the heartbeat |
|
|
68
|
+
| `/cancel-timer all` | Cancel everything |
|
|
69
|
+
|
|
70
|
+
## Best Practices
|
|
71
|
+
|
|
72
|
+
- **Include actionable context** in the message: what to check, what command to run
|
|
73
|
+
- **Set reasonable intervals** — don't heartbeat every 10s for a 30-minute deploy
|
|
74
|
+
- **Stop heartbeats** when done — they keep firing until stopped
|
|
75
|
+
- **Chain timers** for progressive backoff: check at 30s, then 60s, then 120s
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /cancel-timer command — list or cancel timers and heartbeats.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { TimerManager } from "../timer-manager.js";
|
|
7
|
+
|
|
8
|
+
export function registerCancelCommand(pi: ExtensionAPI, manager: TimerManager): void {
|
|
9
|
+
pi.registerCommand("cancel-timer", {
|
|
10
|
+
description: "Cancel a timer by ID, 'heartbeat' to stop the heartbeat, or 'all' for everything",
|
|
11
|
+
handler: async (args, ctx) => {
|
|
12
|
+
const target = args.trim();
|
|
13
|
+
|
|
14
|
+
if (!target) {
|
|
15
|
+
const timers = manager.getActiveTimerIds();
|
|
16
|
+
const hb = manager.getHeartbeatState();
|
|
17
|
+
const hbText = hb
|
|
18
|
+
? `Heartbeat #${hb.id} active (every ${hb.intervalSeconds}s, ${hb.tick} ticks)`
|
|
19
|
+
: "No heartbeat";
|
|
20
|
+
ctx.ui.notify(
|
|
21
|
+
`Timers: ${timers.length ? timers.join(", ") : "none"}. ${hbText}`,
|
|
22
|
+
"info",
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (target === "all") {
|
|
28
|
+
manager.clearAll();
|
|
29
|
+
ctx.ui.notify("All timers and heartbeats cancelled.", "info");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (target === "heartbeat") {
|
|
34
|
+
const stopped = manager.stopHeartbeat();
|
|
35
|
+
if (stopped) {
|
|
36
|
+
ctx.ui.notify(`Heartbeat #${stopped.id} stopped.`, "info");
|
|
37
|
+
} else {
|
|
38
|
+
ctx.ui.notify("No heartbeat was running.", "warning");
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (manager.cancelTimer(target)) {
|
|
44
|
+
ctx.ui.notify(`Timer [${target}] cancelled.`, "info");
|
|
45
|
+
} else {
|
|
46
|
+
ctx.ui.notify(`No timer with ID [${target}] found.`, "warning");
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt injection — tells the agent about timer/heartbeat on every turn.
|
|
3
|
+
*
|
|
4
|
+
* Injected via before_agent_start so the agent knows the tools exist
|
|
5
|
+
* even if the skill isn't loaded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const TIMER_PROMPT = `
|
|
11
|
+
## Non-blocking Timers (pi-heartbeat extension)
|
|
12
|
+
|
|
13
|
+
You have two tools for non-blocking waiting. **Never use \`sleep\` for delays >5 seconds** — it blocks the chat.
|
|
14
|
+
|
|
15
|
+
- **\`timer(seconds, message)\`** — One-shot. Returns immediately, wakes you up after N seconds with the message. Use instead of \`sleep\`.
|
|
16
|
+
- **\`heartbeat(action, interval_seconds, message)\`** — Periodic. Pings you every N seconds. Use for monitoring (deploys, CI, long builds).
|
|
17
|
+
|
|
18
|
+
When you need to wait for something:
|
|
19
|
+
1. Call \`timer(seconds: N, message: "what to check and how")\`
|
|
20
|
+
2. Tell the user you'll check back in Ns
|
|
21
|
+
3. Finish your response — user can chat freely
|
|
22
|
+
4. When woken up, check status and act (or set another timer)
|
|
23
|
+
`.trim();
|
|
24
|
+
|
|
25
|
+
export function registerPromptInjection(pi: ExtensionAPI): void {
|
|
26
|
+
pi.on("before_agent_start", async (event) => {
|
|
27
|
+
return {
|
|
28
|
+
systemPrompt: event.systemPrompt + "\n\n" + TIMER_PROMPT,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sleep interceptor — catches `sleep` in bash commands and redirects to timer tool.
|
|
3
|
+
*
|
|
4
|
+
* Short sleeps (≤5s) are allowed — they're typically "wait for server to boot".
|
|
5
|
+
* Longer sleeps get blocked with a message telling the agent to use `timer` instead.
|
|
6
|
+
*
|
|
7
|
+
* Only blocks when sleep is the LAST (or only) command segment — a leading
|
|
8
|
+
* `sleep 60 && curl something` is fine; a trailing `build && sleep 60` is not.
|
|
9
|
+
*
|
|
10
|
+
* Disable entirely by setting env var: PI_SLEEP_INTERCEPTOR=0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
/** Threshold in seconds — sleeps above this get blocked */
|
|
17
|
+
const SLEEP_THRESHOLD = 5;
|
|
18
|
+
|
|
19
|
+
/** Matches a segment that is SOLELY a sleep call (no operators). */
|
|
20
|
+
const SOLE_SLEEP_PATTERN = /^\s*sleep\s+(\d+(?:\.\d+)?)\s*([smh]?)\s*$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse sleep duration from a SINGLE command segment (no operators).
|
|
24
|
+
* Segment must be SOLELY a sleep command (whitespace OK).
|
|
25
|
+
* Pattern: /^\s*sleep\s+(\d+(?:\.\d+)?)\s*([smh]?)\s*$/
|
|
26
|
+
* Returns null if not a sole sleep.
|
|
27
|
+
*/
|
|
28
|
+
export function segmentSleepSeconds(segment: string): number | null {
|
|
29
|
+
const match = SOLE_SLEEP_PATTERN.exec(segment);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
|
|
32
|
+
const value = parseFloat(match[1]);
|
|
33
|
+
const unit = match[2] || "s";
|
|
34
|
+
|
|
35
|
+
if (unit === "m") return value * 60;
|
|
36
|
+
if (unit === "h") return value * 3600;
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns sleep duration if the command's LAST segment is a sleep call.
|
|
42
|
+
* Splits on &&, ||, |, ; (simple split, best-effort, no quote handling).
|
|
43
|
+
* Trims and filters empty segments. Checks the last one.
|
|
44
|
+
* Returns null if sleep is not the trailing command (or no sleep).
|
|
45
|
+
*/
|
|
46
|
+
export function getTrailingSleepSeconds(command: string): number | null {
|
|
47
|
+
const segments = command
|
|
48
|
+
.split(/&&|\|\||[|;]/)
|
|
49
|
+
.map((s) => s.trim())
|
|
50
|
+
.filter((s) => s.length > 0);
|
|
51
|
+
|
|
52
|
+
if (segments.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
const last = segments[segments.length - 1];
|
|
55
|
+
return segmentSleepSeconds(last);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function registerSleepInterceptor(pi: ExtensionAPI): void {
|
|
59
|
+
if (process.env.PI_SLEEP_INTERCEPTOR === "0") return;
|
|
60
|
+
|
|
61
|
+
pi.on("tool_call", async (event) => {
|
|
62
|
+
if (!isToolCallEventType("bash", event)) return;
|
|
63
|
+
|
|
64
|
+
const command = event.input.command;
|
|
65
|
+
const sleepSeconds = getTrailingSleepSeconds(command);
|
|
66
|
+
|
|
67
|
+
if (sleepSeconds === null || sleepSeconds <= SLEEP_THRESHOLD) return;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
block: true,
|
|
71
|
+
reason:
|
|
72
|
+
`Blocked: \`sleep ${sleepSeconds}s\` blocks the chat — the user can't interact while waiting.\n\n` +
|
|
73
|
+
`Use the \`timer\` tool instead:\n` +
|
|
74
|
+
` timer(seconds: ${Math.ceil(sleepSeconds)}, message: "describe what you're waiting for and what to check")\n\n` +
|
|
75
|
+
`The timer returns immediately (non-blocking), then wakes you up after ${Math.ceil(sleepSeconds)}s.\n` +
|
|
76
|
+
`For periodic checks, use \`heartbeat(action: "start", interval_seconds: ${Math.min(Math.ceil(sleepSeconds), 60)})\`.\n\n` +
|
|
77
|
+
`Short sleeps (≤${SLEEP_THRESHOLD}s) are still allowed for things like waiting for a server to boot.`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-heartbeat — Non-blocking timers and heartbeats for pi agents.
|
|
3
|
+
*
|
|
4
|
+
* Extension entry point. Registers:
|
|
5
|
+
* - timer/heartbeat tools (the alternatives to sleep)
|
|
6
|
+
* - sleep interceptor (blocks sleep >5s in bash, redirects to timer)
|
|
7
|
+
* - system prompt injection (teaches the agent about the tools)
|
|
8
|
+
* - /cancel-timer command
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { registerTimerTool } from "./tools/timer.js";
|
|
13
|
+
import { registerHeartbeatTool } from "./tools/heartbeat.js";
|
|
14
|
+
import { TimerManager } from "./timer-manager.js";
|
|
15
|
+
import { registerCancelCommand } from "./commands/cancel.js";
|
|
16
|
+
import { registerSleepInterceptor } from "./guards/sleep-interceptor.js";
|
|
17
|
+
import { registerPromptInjection } from "./guards/prompt-injection.js";
|
|
18
|
+
|
|
19
|
+
export default function activate(pi: ExtensionAPI): void {
|
|
20
|
+
const manager = new TimerManager(pi);
|
|
21
|
+
|
|
22
|
+
// Tools
|
|
23
|
+
registerTimerTool(pi, manager);
|
|
24
|
+
registerHeartbeatTool(pi, manager);
|
|
25
|
+
|
|
26
|
+
// Guards — block sleep, inject prompt
|
|
27
|
+
registerSleepInterceptor(pi);
|
|
28
|
+
registerPromptInjection(pi);
|
|
29
|
+
|
|
30
|
+
// Commands
|
|
31
|
+
registerCancelCommand(pi, manager);
|
|
32
|
+
|
|
33
|
+
// Cleanup
|
|
34
|
+
pi.on("session_shutdown", async () => {
|
|
35
|
+
manager.clearAll();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimerManager — Centralized timer/heartbeat state management.
|
|
3
|
+
*
|
|
4
|
+
* Handles one-shot timers and periodic heartbeats.
|
|
5
|
+
* Fires messages via pi.sendMessage({ triggerTurn: true }) to wake the agent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export interface ActiveTimer {
|
|
11
|
+
id: string;
|
|
12
|
+
seconds: number;
|
|
13
|
+
message: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HeartbeatState {
|
|
18
|
+
id: number;
|
|
19
|
+
intervalSeconds: number;
|
|
20
|
+
message: string;
|
|
21
|
+
tick: number;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class TimerManager {
|
|
26
|
+
private timers = new Map<string, NodeJS.Timeout>();
|
|
27
|
+
private timerCounter = 0;
|
|
28
|
+
private heartbeatInterval: NodeJS.Timeout | undefined;
|
|
29
|
+
private heartbeatState: HeartbeatState | undefined;
|
|
30
|
+
private heartbeatIdCounter = 0;
|
|
31
|
+
|
|
32
|
+
constructor(private pi: ExtensionAPI) {}
|
|
33
|
+
|
|
34
|
+
// ── One-shot timers ─────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
setTimer(seconds: number, message: string, id?: string): string {
|
|
37
|
+
const timerId = id ?? `timer-${++this.timerCounter}`;
|
|
38
|
+
|
|
39
|
+
// Cancel existing timer with same ID
|
|
40
|
+
this.cancelTimer(timerId);
|
|
41
|
+
|
|
42
|
+
const timeout = setTimeout(() => {
|
|
43
|
+
this.timers.delete(timerId);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
this.pi.sendMessage(
|
|
47
|
+
{
|
|
48
|
+
customType: "heartbeat-timer",
|
|
49
|
+
content: `⏰ Timer [${timerId}] fired (after ${seconds}s): ${message}`,
|
|
50
|
+
display: true,
|
|
51
|
+
details: {
|
|
52
|
+
type: "timer",
|
|
53
|
+
timerId,
|
|
54
|
+
seconds,
|
|
55
|
+
message,
|
|
56
|
+
firedAt: new Date().toISOString(),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{ triggerTurn: true },
|
|
60
|
+
);
|
|
61
|
+
} catch {
|
|
62
|
+
// The extension ctx is stale (session replaced/reloaded) or the
|
|
63
|
+
// session is shutting down. sendMessage will never succeed on
|
|
64
|
+
// this ctx, so there's nothing useful to do. The timer has
|
|
65
|
+
// already been removed from `this.timers` above. Swallow to
|
|
66
|
+
// prevent an uncaughtException that would kill the pi process.
|
|
67
|
+
}
|
|
68
|
+
}, seconds * 1000);
|
|
69
|
+
|
|
70
|
+
this.timers.set(timerId, timeout);
|
|
71
|
+
return timerId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
cancelTimer(id: string): boolean {
|
|
75
|
+
const timeout = this.timers.get(id);
|
|
76
|
+
if (timeout) {
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
this.timers.delete(id);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
getActiveTimerIds(): string[] {
|
|
85
|
+
return [...this.timers.keys()];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Heartbeat (periodic) ────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
startHeartbeat(intervalSeconds: number, message: string): HeartbeatState {
|
|
91
|
+
this.stopHeartbeat();
|
|
92
|
+
|
|
93
|
+
this.heartbeatIdCounter++;
|
|
94
|
+
const currentId = this.heartbeatIdCounter;
|
|
95
|
+
|
|
96
|
+
this.heartbeatState = {
|
|
97
|
+
id: currentId,
|
|
98
|
+
intervalSeconds,
|
|
99
|
+
message,
|
|
100
|
+
tick: 0,
|
|
101
|
+
createdAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const state = this.heartbeatState;
|
|
105
|
+
|
|
106
|
+
this.heartbeatInterval = setInterval(() => {
|
|
107
|
+
state.tick++;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
this.pi.sendMessage(
|
|
111
|
+
{
|
|
112
|
+
customType: "heartbeat-ping",
|
|
113
|
+
content: `💓 Heartbeat #${currentId} tick ${state.tick} (every ${intervalSeconds}s): ${message}`,
|
|
114
|
+
display: true,
|
|
115
|
+
details: {
|
|
116
|
+
type: "heartbeat",
|
|
117
|
+
heartbeatId: currentId,
|
|
118
|
+
tick: state.tick,
|
|
119
|
+
intervalSeconds,
|
|
120
|
+
message,
|
|
121
|
+
firedAt: new Date().toISOString(),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{ triggerTurn: true },
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
// The extension ctx is stale (session replaced/reloaded) or the
|
|
128
|
+
// session is shutting down. Stop the interval so we don't keep
|
|
129
|
+
// throwing on every tick — a zombie interval that wastes CPU
|
|
130
|
+
// and risks crashing the process via uncaughtException.
|
|
131
|
+
this.stopHeartbeat();
|
|
132
|
+
}
|
|
133
|
+
}, intervalSeconds * 1000);
|
|
134
|
+
|
|
135
|
+
return { ...state };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
stopHeartbeat(): HeartbeatState | undefined {
|
|
139
|
+
const state = this.heartbeatState;
|
|
140
|
+
if (this.heartbeatInterval) {
|
|
141
|
+
clearInterval(this.heartbeatInterval);
|
|
142
|
+
this.heartbeatInterval = undefined;
|
|
143
|
+
this.heartbeatState = undefined;
|
|
144
|
+
}
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getHeartbeatState(): HeartbeatState | undefined {
|
|
149
|
+
return this.heartbeatState ? { ...this.heartbeatState } : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
isHeartbeatActive(): boolean {
|
|
153
|
+
return this.heartbeatInterval !== undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Cleanup ─────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
clearAll(): void {
|
|
159
|
+
for (const [, t] of this.timers) {
|
|
160
|
+
clearTimeout(t);
|
|
161
|
+
}
|
|
162
|
+
this.timers.clear();
|
|
163
|
+
this.stopHeartbeat();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat tool — periodic non-blocking "ping me every N seconds".
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
import type { TimerManager } from "../timer-manager.js";
|
|
8
|
+
|
|
9
|
+
export function registerHeartbeatTool(pi: ExtensionAPI, manager: TimerManager): void {
|
|
10
|
+
pi.registerTool({
|
|
11
|
+
name: "heartbeat",
|
|
12
|
+
label: "Heartbeat",
|
|
13
|
+
description:
|
|
14
|
+
"Start or stop a periodic heartbeat. When running, you get woken up every N seconds " +
|
|
15
|
+
"with a status check message. Use for long-running monitoring " +
|
|
16
|
+
"(deployment progress, CI pipeline, server health, process supervision). " +
|
|
17
|
+
"Only one heartbeat can be active at a time. Starting a new one stops the previous.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
action: Type.Union([Type.Literal("start"), Type.Literal("stop"), Type.Literal("status")]),
|
|
20
|
+
interval_seconds: Type.Optional(
|
|
21
|
+
Type.Number({
|
|
22
|
+
description: "Interval between heartbeats in seconds (default: 60, min: 10)",
|
|
23
|
+
minimum: 10,
|
|
24
|
+
maximum: 3600,
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
message: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description:
|
|
30
|
+
"Context for each heartbeat ping, e.g. 'Check deployment status on staging'",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
34
|
+
|
|
35
|
+
async execute(_toolCallId, params): Promise<{ content: { type: "text"; text: string }[]; details: Record<string, any> }> {
|
|
36
|
+
if (params.action === "status") {
|
|
37
|
+
const state = manager.getHeartbeatState();
|
|
38
|
+
if (state) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text" as const,
|
|
43
|
+
text: `Heartbeat #${state.id} active — every ${state.intervalSeconds}s, ${state.tick} ticks so far. Message: "${state.message}"`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
details: { active: true, ...state },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const timers = manager.getActiveTimerIds();
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: "text" as const,
|
|
54
|
+
text: `No heartbeat active. Active timers: ${timers.length ? timers.join(", ") : "none"}.`,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
details: { active: false, timers },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (params.action === "stop") {
|
|
62
|
+
const stopped = manager.stopHeartbeat();
|
|
63
|
+
if (stopped) {
|
|
64
|
+
return {
|
|
65
|
+
content: [
|
|
66
|
+
{
|
|
67
|
+
type: "text" as const,
|
|
68
|
+
text: `Heartbeat #${stopped.id} stopped after ${stopped.tick} ticks.`,
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
details: { stopped: true, ...stopped },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text" as const, text: "No heartbeat was running." }],
|
|
76
|
+
details: { stopped: false },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// action === "start"
|
|
81
|
+
const intervalSec = params.interval_seconds ?? 60;
|
|
82
|
+
const msg = params.message ?? "Periodic status check";
|
|
83
|
+
const state = manager.startHeartbeat(intervalSec, msg);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: "text" as const,
|
|
89
|
+
text:
|
|
90
|
+
`Heartbeat #${state.id} started — pinging every ${intervalSec}s. ` +
|
|
91
|
+
`Message: "${msg}". ` +
|
|
92
|
+
`Use heartbeat(action: "stop") to cancel. ` +
|
|
93
|
+
`Finish your response — the user can chat freely.`,
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
details: { heartbeatId: state.id, intervalSeconds: intervalSec },
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timer tool — one-shot non-blocking "wake me up in N seconds".
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "@sinclair/typebox";
|
|
7
|
+
import type { TimerManager } from "../timer-manager.js";
|
|
8
|
+
|
|
9
|
+
export function registerTimerTool(pi: ExtensionAPI, manager: TimerManager): void {
|
|
10
|
+
pi.registerTool({
|
|
11
|
+
name: "timer",
|
|
12
|
+
label: "Timer",
|
|
13
|
+
description:
|
|
14
|
+
"Set a non-blocking timer. Returns immediately — you go idle and the user can chat. " +
|
|
15
|
+
"When the timer fires, you get woken up with the message. " +
|
|
16
|
+
"Use this instead of `sleep` when you need to wait for something " +
|
|
17
|
+
"(build completion, deployment, polling intervals, etc.). " +
|
|
18
|
+
"You can set multiple timers simultaneously.",
|
|
19
|
+
parameters: Type.Object({
|
|
20
|
+
seconds: Type.Number({
|
|
21
|
+
description: "Delay in seconds before waking up",
|
|
22
|
+
minimum: 1,
|
|
23
|
+
maximum: 3600,
|
|
24
|
+
}),
|
|
25
|
+
message: Type.String({
|
|
26
|
+
description:
|
|
27
|
+
"Context message delivered when timer fires. Include what you were waiting for " +
|
|
28
|
+
"and what to do next, e.g. 'Check if build #42 finished — run `gh run view 42`'",
|
|
29
|
+
}),
|
|
30
|
+
id: Type.Optional(
|
|
31
|
+
Type.String({
|
|
32
|
+
description: "Optional timer ID for cancellation. Auto-generated if omitted.",
|
|
33
|
+
}),
|
|
34
|
+
),
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
async execute(_toolCallId, params) {
|
|
38
|
+
const timerId = manager.setTimer(params.seconds, params.message, params.id);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text" as const,
|
|
44
|
+
text:
|
|
45
|
+
`Timer [${timerId}] set for ${params.seconds}s. ` +
|
|
46
|
+
`You will be woken up with: "${params.message}". ` +
|
|
47
|
+
`Finish your response — the user can chat freely while waiting.`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
details: { timerId, seconds: params.seconds },
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|