reeboot 1.0.0 → 1.3.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 +218 -7
- package/extensions/scheduler-tool.ts +360 -8
- package/extensions/skill-manager.ts +421 -0
- package/extensions/web-search.ts +466 -0
- package/package.json +5 -3
- package/skills/docker/SKILL.md +131 -0
- package/skills/files/SKILL.md +94 -0
- package/skills/gcal/SKILL.md +69 -0
- package/skills/gdrive/SKILL.md +65 -0
- package/skills/github/SKILL.md +80 -0
- package/skills/gmail/SKILL.md +68 -0
- package/skills/hubspot/SKILL.md +77 -0
- package/skills/linear/SKILL.md +78 -0
- package/skills/notion/SKILL.md +85 -0
- package/skills/postgres/SKILL.md +75 -0
- package/skills/reeboot-tasks/SKILL.md +98 -0
- package/skills/send-message/SKILL.md +52 -14
- package/skills/slack/SKILL.md +74 -0
- package/skills/sqlite/SKILL.md +85 -0
- package/skills/web-research/SKILL.md +47 -0
- package/templates/models-ollama.json +16 -0
- package/skills/web-search/SKILL.md +0 -32
package/README.md
CHANGED
|
@@ -7,16 +7,22 @@
|
|
|
7
7
|
## Quick Start
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
# Install and launch the setup wizard
|
|
11
|
-
npx reeboot
|
|
12
|
-
|
|
13
|
-
# Or install globally
|
|
14
10
|
npm install -g reeboot
|
|
15
|
-
reeboot
|
|
16
|
-
reeboot start
|
|
11
|
+
reeboot
|
|
17
12
|
```
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
That's it. On first run, `reeboot` detects that no config exists and launches the guided setup wizard automatically. The wizard walks you through:
|
|
15
|
+
|
|
16
|
+
1. **AI Provider** — choose from 8 providers (Anthropic, OpenAI, Google, Groq, Mistral, xAI, OpenRouter, Ollama)
|
|
17
|
+
2. **Agent Name** — give your agent a name (default: Reeboot)
|
|
18
|
+
3. **Channels** — optionally link WhatsApp or Signal inline (QR code shown in terminal)
|
|
19
|
+
4. **Web Search** — choose DuckDuckGo (default), Brave, Tavily, Serper, Exa, SearXNG, or None
|
|
20
|
+
|
|
21
|
+
After setup, the wizard offers to start your agent immediately. On subsequent runs, `reeboot` detects the existing config and starts the agent directly — no flags needed.
|
|
22
|
+
|
|
23
|
+
To re-run setup: `reeboot setup` (asks before overwriting your existing config).
|
|
24
|
+
|
|
25
|
+
Open the WebChat URL printed on startup, or send a message to your linked WhatsApp/Signal number.
|
|
20
26
|
|
|
21
27
|
---
|
|
22
28
|
|
|
@@ -189,6 +195,91 @@ reeboot uninstall reeboot-github-tools
|
|
|
189
195
|
}
|
|
190
196
|
```
|
|
191
197
|
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Web Search
|
|
201
|
+
|
|
202
|
+
Reeboot includes a built-in web search extension that registers two agent tools:
|
|
203
|
+
|
|
204
|
+
- **`fetch_url`** — Always available. Fetches any URL and returns clean readable text (Readability extraction with HTML-strip fallback).
|
|
205
|
+
- **`web_search`** — Available when `search.provider` is not `"none"`. Searches the web via the configured backend and returns an array of `{ title, url, snippet }` results.
|
|
206
|
+
|
|
207
|
+
### Providers
|
|
208
|
+
|
|
209
|
+
| Provider | Free Tier | Config |
|
|
210
|
+
|----------|-----------|--------|
|
|
211
|
+
| `duckduckgo` | ✅ Zero config, HTML scraping | No API key needed |
|
|
212
|
+
| `brave` | ✅ 2,000 queries/month free | `BRAVE_API_KEY` or `config.search.apiKey` |
|
|
213
|
+
| `tavily` | ✅ 1,000 queries/month free | `TAVILY_API_KEY` or `config.search.apiKey` |
|
|
214
|
+
| `serper` | ✅ 2,500 queries free | `SERPER_API_KEY` or `config.search.apiKey` |
|
|
215
|
+
| `exa` | ✅ 1,000 queries/month free | `EXA_API_KEY` or `config.search.apiKey` |
|
|
216
|
+
| `searxng` | ✅ Self-hosted (Docker) | `searxngBaseUrl` in config |
|
|
217
|
+
| `none` | — | Disables `web_search`; `fetch_url` still available |
|
|
218
|
+
|
|
219
|
+
### Configuration
|
|
220
|
+
|
|
221
|
+
Add a `search` block to `~/.reeboot/config.json`:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"search": {
|
|
226
|
+
"provider": "duckduckgo"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For API-key providers:
|
|
232
|
+
|
|
233
|
+
```json
|
|
234
|
+
{
|
|
235
|
+
"search": {
|
|
236
|
+
"provider": "brave",
|
|
237
|
+
"apiKey": "your-brave-api-key"
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Or set the env var instead of storing the key in config:
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
export BRAVE_API_KEY=your-key # for brave
|
|
246
|
+
export TAVILY_API_KEY=your-key # for tavily
|
|
247
|
+
export SERPER_API_KEY=your-key # for serper
|
|
248
|
+
export EXA_API_KEY=your-key # for exa
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### SearXNG (Self-Hosted)
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{
|
|
255
|
+
"search": {
|
|
256
|
+
"provider": "searxng",
|
|
257
|
+
"searxngBaseUrl": "http://localhost:8080"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Start SearXNG with Docker:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
docker run -d -p 8080:8080 searxng/searxng
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
If SearXNG is unreachable at agent startup, reeboot automatically falls back to DuckDuckGo for the session.
|
|
269
|
+
|
|
270
|
+
### Disabling Web Search
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"search": {
|
|
275
|
+
"provider": "none"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`fetch_url` remains available even when `provider = "none"`.
|
|
281
|
+
|
|
282
|
+
|
|
192
283
|
---
|
|
193
284
|
|
|
194
285
|
## WhatsApp Setup
|
|
@@ -251,6 +342,59 @@ Then: `reeboot start`
|
|
|
251
342
|
|
|
252
343
|
---
|
|
253
344
|
|
|
345
|
+
## Bundled Skills
|
|
346
|
+
|
|
347
|
+
Reeboot ships 15 skills inside the package — no extra install needed. The agent can load them on demand via `load_skill("name")` or you can make them permanently available via config.
|
|
348
|
+
|
|
349
|
+
| Skill | What it does | Requires |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `github` | Issues, PRs, releases, Actions, code search | `gh` CLI + `gh auth login` |
|
|
352
|
+
| `gmail` | Search, read, send, draft, labels, attachments | `gmcli` npm CLI + GCP OAuth |
|
|
353
|
+
| `gcal` | List, create, update, delete calendar events | `gccli` npm CLI + GCP OAuth |
|
|
354
|
+
| `gdrive` | List, read, upload, search Drive files | `gdcli` npm CLI + GCP OAuth |
|
|
355
|
+
| `notion` | Pages, databases, blocks, search | `NOTION_API_KEY` env var |
|
|
356
|
+
| `slack` | Send messages, list channels, thread replies | `SLACK_BOT_TOKEN` env var |
|
|
357
|
+
| `linear` | Issues, projects, teams, cycles | `LINEAR_API_KEY` env var |
|
|
358
|
+
| `hubspot` | Contacts, deals, companies, pipelines | `HUBSPOT_ACCESS_TOKEN` env var |
|
|
359
|
+
| `postgres` | Query, inspect schema, run statements | `psql` CLI + `DATABASE_URL` |
|
|
360
|
+
| `sqlite` | Query, inspect tables, run statements | `sqlite3` CLI + `DATABASE_PATH` |
|
|
361
|
+
| `docker` | Containers, images, compose stacks | `docker` CLI |
|
|
362
|
+
| `files` | Read, write, search local filesystem | bash (built-in) |
|
|
363
|
+
| `reeboot-tasks` | Schedule, list, pause, cancel own tasks | scheduler extension (built-in) |
|
|
364
|
+
| `web-research` | Structured multi-query web research | web-search extension |
|
|
365
|
+
| `send-message` | Send a message to the originating channel | reeboot channels (built-in) |
|
|
366
|
+
|
|
367
|
+
### Skill configuration
|
|
368
|
+
|
|
369
|
+
```yaml
|
|
370
|
+
# ~/.reeboot/config.yaml
|
|
371
|
+
skills:
|
|
372
|
+
permanent: [github, gmail] # always in context
|
|
373
|
+
ephemeral_ttl_minutes: 60 # default lifetime for on-demand loads
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
### Managing skills
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
reeboot skills list # browse all 15 bundled skills
|
|
380
|
+
reeboot skills update # pull extended catalog (coming soon)
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
The agent can also manage its own skills:
|
|
384
|
+
|
|
385
|
+
```
|
|
386
|
+
User: load the notion skill for 30 minutes
|
|
387
|
+
Agent: → calls load_skill("notion", 30)
|
|
388
|
+
|
|
389
|
+
User: what integrations do you have available?
|
|
390
|
+
Agent: → calls list_available_skills()
|
|
391
|
+
|
|
392
|
+
User: unload notion, I'm done
|
|
393
|
+
Agent: → calls unload_skill("notion")
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
254
398
|
## CLI Reference
|
|
255
399
|
|
|
256
400
|
```
|
|
@@ -270,6 +414,9 @@ Commands:
|
|
|
270
414
|
|
|
271
415
|
packages list List installed packages
|
|
272
416
|
|
|
417
|
+
skills list List all bundled skills
|
|
418
|
+
skills update Update extended skill catalog
|
|
419
|
+
|
|
273
420
|
channel list List channels and their status
|
|
274
421
|
channel login <ch> Authenticate a channel (whatsapp, signal)
|
|
275
422
|
channel logout <ch> Disconnect a channel
|
|
@@ -359,3 +506,67 @@ MIT
|
|
|
359
506
|
- [npm package](https://www.npmjs.com/package/reeboot)
|
|
360
507
|
- [Docker Hub](https://hub.docker.com/r/reeboot/reeboot)
|
|
361
508
|
- [Architecture decisions](../architecture-decisions.md)
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## Proactive Agent
|
|
513
|
+
|
|
514
|
+
Reeboot supports a **proactive agent** mode where the agent can wake itself up, check for tasks, and act without being asked.
|
|
515
|
+
|
|
516
|
+
### System Heartbeat
|
|
517
|
+
|
|
518
|
+
The system heartbeat fires at a configurable interval and dispatches a prompt to the agent with the current task snapshot. If the agent has nothing to do, it responds with `IDLE` (silently suppressed). Otherwise, the response is sent to the default channel.
|
|
519
|
+
|
|
520
|
+
Configure in `~/.reeboot/config.json`:
|
|
521
|
+
|
|
522
|
+
```json
|
|
523
|
+
{
|
|
524
|
+
"heartbeat": {
|
|
525
|
+
"enabled": true,
|
|
526
|
+
"interval": "every 5m",
|
|
527
|
+
"contextId": "main"
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
- `enabled`: Default `false`. Set to `true` to enable.
|
|
533
|
+
- `interval`: Human-friendly interval string (same parser as `schedule_task`). Examples: `"every 5m"`, `"every 1h"`, `"daily"`.
|
|
534
|
+
- `contextId`: Which context the heartbeat runs in. Default `"main"`.
|
|
535
|
+
|
|
536
|
+
### In-Session Timer Tool
|
|
537
|
+
|
|
538
|
+
The `timer` tool lets the agent set a **non-blocking** one-shot wait. It returns immediately and fires a new agent turn after the delay:
|
|
539
|
+
|
|
540
|
+
```
|
|
541
|
+
timer(seconds: 10, message: "Check build status", id: "build-check")
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
- `seconds`: 1–3600
|
|
545
|
+
- `message`: Included in the wake-up message
|
|
546
|
+
- `id` (optional): If a timer with the same id exists, it is replaced
|
|
547
|
+
|
|
548
|
+
### In-Session Heartbeat Tool
|
|
549
|
+
|
|
550
|
+
The `heartbeat` tool starts a periodic non-blocking wake-up:
|
|
551
|
+
|
|
552
|
+
```
|
|
553
|
+
heartbeat(action: "start", interval_seconds: 60, message: "Deploy check")
|
|
554
|
+
heartbeat(action: "stop")
|
|
555
|
+
heartbeat(action: "status")
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- Only one heartbeat is active per session. Starting a new one replaces the previous.
|
|
559
|
+
- `interval_seconds`: 10–3600
|
|
560
|
+
|
|
561
|
+
### Sleep Interceptor
|
|
562
|
+
|
|
563
|
+
The extension automatically blocks `sleep` when it is the **sole or last** command in a bash chain, redirecting the agent to use `timer` instead:
|
|
564
|
+
|
|
565
|
+
| Command | Outcome |
|
|
566
|
+
|---------|---------|
|
|
567
|
+
| `sleep 60` | ❌ Blocked — use `timer(60, msg)` |
|
|
568
|
+
| `npm build && sleep 60` | ❌ Blocked — sleep is last |
|
|
569
|
+
| `sleep 2 && npm start` | ✅ Allowed — sleep is not last |
|
|
570
|
+
| `npm build \|\| sleep 5` | ✅ Allowed — `\|\|` is not a split point |
|
|
571
|
+
|
|
572
|
+
Disable the interceptor: `REEBOOT_SLEEP_INTERCEPTOR=0 reeboot start`
|
|
@@ -1,18 +1,134 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduler Tool Extension
|
|
3
3
|
*
|
|
4
|
-
* Registers schedule_task, list_tasks, cancel_task
|
|
5
|
-
*
|
|
6
|
-
* (
|
|
4
|
+
* Registers schedule_task, list_tasks, cancel_task, pause_task, resume_task,
|
|
5
|
+
* update_task tools backed by SQLite, plus the /tasks slash command.
|
|
6
|
+
* Uses getDb() for DB access and integrates with the Scheduler singleton.
|
|
7
|
+
*
|
|
8
|
+
* Also registers:
|
|
9
|
+
* - timer: one-shot non-blocking wait (fires pi.sendMessage triggerTurn)
|
|
10
|
+
* - heartbeat: periodic non-blocking wake-up (start/stop/status)
|
|
11
|
+
* - bash pre-hook: sleep interceptor (blocks sleep when sole/last command)
|
|
12
|
+
* - session_shutdown: cleans up all in-session timers and heartbeat
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
15
|
import { Type } from '@sinclair/typebox';
|
|
10
16
|
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
|
11
17
|
|
|
18
|
+
// ─── isSleepOnlyOrLast ────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns true if sleep is the sole command or the last command in a chain.
|
|
22
|
+
* Splits on && and single | (not || which is OR-fallback).
|
|
23
|
+
*/
|
|
24
|
+
export function isSleepOnlyOrLast(command: string): boolean {
|
|
25
|
+
// Split on && and | (pipe) — but not || (double pipe for fallback)
|
|
26
|
+
const parts = command.trim().split(/&&|(?<!\|)\|(?!\|)/).map((s) => s.trim()).filter(Boolean);
|
|
27
|
+
if (parts.length === 0) return false;
|
|
28
|
+
const last = parts[parts.length - 1];
|
|
29
|
+
return last.startsWith('sleep ') || last === 'sleep';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── TimerManager ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export class TimerManager {
|
|
35
|
+
private _timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
36
|
+
private _heartbeat: {
|
|
37
|
+
interval: ReturnType<typeof setInterval>;
|
|
38
|
+
tickCount: number;
|
|
39
|
+
message: string;
|
|
40
|
+
intervalSeconds: number;
|
|
41
|
+
startedAt: Date;
|
|
42
|
+
} | null = null;
|
|
43
|
+
|
|
44
|
+
// ── Timer ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
setTimer(pi: ExtensionAPI, seconds: number, message: string, id: string): void {
|
|
47
|
+
if (seconds < 1 || seconds > 3600) {
|
|
48
|
+
throw new Error('seconds must be between 1 and 3600');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cancel existing timer with same id
|
|
52
|
+
const existing = this._timers.get(id);
|
|
53
|
+
if (existing) clearTimeout(existing);
|
|
54
|
+
|
|
55
|
+
const handle = setTimeout(() => {
|
|
56
|
+
this._timers.delete(id);
|
|
57
|
+
pi.sendMessage(
|
|
58
|
+
{ content: `⏰ Timer ${id} fired: ${message}`, display: true },
|
|
59
|
+
{ triggerTurn: true }
|
|
60
|
+
);
|
|
61
|
+
}, seconds * 1000);
|
|
62
|
+
|
|
63
|
+
this._timers.set(id, handle);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
cancelTimer(id: string): void {
|
|
67
|
+
const handle = this._timers.get(id);
|
|
68
|
+
if (handle) {
|
|
69
|
+
clearTimeout(handle);
|
|
70
|
+
this._timers.delete(id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Heartbeat ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
startHeartbeat(pi: ExtensionAPI, intervalSeconds: number, message: string): void {
|
|
77
|
+
if (intervalSeconds < 10 || intervalSeconds > 3600) {
|
|
78
|
+
throw new Error('interval_seconds must be between 10 and 3600');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Stop any existing heartbeat
|
|
82
|
+
this.stopHeartbeat();
|
|
83
|
+
|
|
84
|
+
let tickCount = 0;
|
|
85
|
+
const startedAt = new Date();
|
|
86
|
+
|
|
87
|
+
const handle = setInterval(() => {
|
|
88
|
+
tickCount++;
|
|
89
|
+
if (this._heartbeat) this._heartbeat.tickCount = tickCount;
|
|
90
|
+
pi.sendMessage(
|
|
91
|
+
{ content: `💓 Heartbeat tick ${tickCount}: ${message}`, display: true },
|
|
92
|
+
{ triggerTurn: true }
|
|
93
|
+
);
|
|
94
|
+
}, intervalSeconds * 1000);
|
|
95
|
+
|
|
96
|
+
this._heartbeat = {
|
|
97
|
+
interval: handle,
|
|
98
|
+
tickCount: 0,
|
|
99
|
+
message,
|
|
100
|
+
intervalSeconds,
|
|
101
|
+
startedAt,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stopHeartbeat(): void {
|
|
106
|
+
if (this._heartbeat) {
|
|
107
|
+
clearInterval(this._heartbeat.interval);
|
|
108
|
+
this._heartbeat = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getHeartbeatStatus(): string {
|
|
113
|
+
if (!this._heartbeat) return 'No active heartbeat.';
|
|
114
|
+
const elapsed = Math.round((Date.now() - this._heartbeat.startedAt.getTime()) / 1000);
|
|
115
|
+
return `Active heartbeat: every ${this._heartbeat.intervalSeconds}s, message="${this._heartbeat.message}", ticks=${this._heartbeat.tickCount}, running for ${elapsed}s`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Cleanup ────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
clearAll(): void {
|
|
121
|
+
for (const h of this._timers.values()) clearTimeout(h);
|
|
122
|
+
this._timers.clear();
|
|
123
|
+
this.stopHeartbeat();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Extension default export ─────────────────────────────────────────────────
|
|
128
|
+
|
|
12
129
|
export default function (pi: ExtensionAPI) {
|
|
13
130
|
// Lazily resolve DB and scheduler to avoid circular imports
|
|
14
131
|
function getTools() {
|
|
15
|
-
// Dynamic requires deferred to avoid startup issues
|
|
16
132
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
133
|
const { getDb } = require('../src/db/index.js') as typeof import('../src/db/index.js');
|
|
18
134
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -24,14 +140,149 @@ export default function (pi: ExtensionAPI) {
|
|
|
24
140
|
return createSchedulerTools(db, globalScheduler);
|
|
25
141
|
}
|
|
26
142
|
|
|
143
|
+
// ─── In-session timer manager ──────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
const manager = new TimerManager();
|
|
146
|
+
|
|
147
|
+
// ─── session_shutdown cleanup ──────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
pi.on('session_shutdown', () => {
|
|
150
|
+
manager.clearAll();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Sleep interceptor (bash pre-hook) ────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
pi.on('user_bash', (event): any => {
|
|
156
|
+
if (process.env.REEBOOT_SLEEP_INTERCEPTOR === '0') return;
|
|
157
|
+
if (isSleepOnlyOrLast(event.command)) {
|
|
158
|
+
return {
|
|
159
|
+
result: {
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: 'text',
|
|
163
|
+
text: 'Blocking sleep command. Use timer(seconds, message) for non-blocking waits.',
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
details: {},
|
|
167
|
+
isError: true,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── timer tool ───────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
pi.registerTool({
|
|
176
|
+
name: 'timer',
|
|
177
|
+
label: 'Timer',
|
|
178
|
+
description:
|
|
179
|
+
'Set a one-shot non-blocking timer. Returns immediately. After the specified delay, fires a new agent turn with the given message. Use instead of sleep.',
|
|
180
|
+
parameters: Type.Object({
|
|
181
|
+
seconds: Type.Number({ description: 'Delay in seconds (1–3600)' }),
|
|
182
|
+
message: Type.String({ description: 'Message to include when the timer fires' }),
|
|
183
|
+
id: Type.Optional(Type.String({ description: 'Timer id (optional). Same id cancels previous timer.' })),
|
|
184
|
+
}),
|
|
185
|
+
execute: async (_callId, params) => {
|
|
186
|
+
const id = params.id ?? `timer-${Date.now()}`;
|
|
187
|
+
try {
|
|
188
|
+
manager.setTimer(pi, params.seconds, params.message, id);
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: 'text' as const,
|
|
193
|
+
text: `Timer "${id}" set for ${params.seconds}s: "${params.message}"`,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
details: { id, seconds: params.seconds, message: params.message },
|
|
197
|
+
};
|
|
198
|
+
} catch (err: any) {
|
|
199
|
+
return {
|
|
200
|
+
content: [{ type: 'text' as const, text: err.message }],
|
|
201
|
+
details: {},
|
|
202
|
+
isError: true,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ─── heartbeat tool ───────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
pi.registerTool({
|
|
211
|
+
name: 'heartbeat',
|
|
212
|
+
label: 'Heartbeat',
|
|
213
|
+
description:
|
|
214
|
+
'Manage a periodic non-blocking heartbeat. Actions: start (requires interval_seconds 10–3600 and message), stop, status. Only one heartbeat active at a time.',
|
|
215
|
+
parameters: Type.Object({
|
|
216
|
+
action: Type.Union([Type.Literal('start'), Type.Literal('stop'), Type.Literal('status')], {
|
|
217
|
+
description: 'Action to perform',
|
|
218
|
+
}),
|
|
219
|
+
interval_seconds: Type.Optional(
|
|
220
|
+
Type.Number({ description: 'Interval in seconds (10–3600). Required for start.' })
|
|
221
|
+
),
|
|
222
|
+
message: Type.Optional(
|
|
223
|
+
Type.String({ description: 'Message to include on each tick. Required for start.' })
|
|
224
|
+
),
|
|
225
|
+
}),
|
|
226
|
+
execute: async (_callId, params) => {
|
|
227
|
+
if (params.action === 'start') {
|
|
228
|
+
const intervalSeconds = params.interval_seconds ?? 60;
|
|
229
|
+
const message = params.message ?? 'Heartbeat tick';
|
|
230
|
+
try {
|
|
231
|
+
manager.startHeartbeat(pi, intervalSeconds, message);
|
|
232
|
+
return {
|
|
233
|
+
content: [
|
|
234
|
+
{
|
|
235
|
+
type: 'text' as const,
|
|
236
|
+
text: `Heartbeat started: every ${intervalSeconds}s, message="${message}"`,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
details: { intervalSeconds, message },
|
|
240
|
+
};
|
|
241
|
+
} catch (err: any) {
|
|
242
|
+
return {
|
|
243
|
+
content: [{ type: 'text' as const, text: err.message }],
|
|
244
|
+
details: {},
|
|
245
|
+
isError: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (params.action === 'stop') {
|
|
251
|
+
manager.stopHeartbeat();
|
|
252
|
+
return {
|
|
253
|
+
content: [{ type: 'text' as const, text: 'Heartbeat stopped.' }],
|
|
254
|
+
details: {},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// status
|
|
259
|
+
const status = manager.getHeartbeatStatus();
|
|
260
|
+
return {
|
|
261
|
+
content: [{ type: 'text' as const, text: status }],
|
|
262
|
+
details: {},
|
|
263
|
+
};
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── schedule_task ────────────────────────────────────────────────────────
|
|
268
|
+
|
|
27
269
|
pi.registerTool({
|
|
28
270
|
name: 'schedule_task',
|
|
29
271
|
label: 'Schedule Task',
|
|
30
|
-
description:
|
|
272
|
+
description:
|
|
273
|
+
'Schedule a task. Provide a human-friendly schedule string (e.g. "every 30m", "daily", "0 9 * * *", "2026-04-01T09:00:00Z"), a prompt, and optionally contextId and context_mode.',
|
|
31
274
|
parameters: Type.Object({
|
|
32
|
-
schedule: Type.String({
|
|
275
|
+
schedule: Type.String({
|
|
276
|
+
description:
|
|
277
|
+
'Schedule: cron expression, ISO datetime, or interval like "every 30m", "hourly", "daily"',
|
|
278
|
+
}),
|
|
33
279
|
prompt: Type.String({ description: 'Prompt to dispatch to the agent on schedule' }),
|
|
34
|
-
contextId: Type.Optional(
|
|
280
|
+
contextId: Type.Optional(
|
|
281
|
+
Type.String({ description: 'Context to run in (default: main)' })
|
|
282
|
+
),
|
|
283
|
+
context_mode: Type.Optional(
|
|
284
|
+
Type.String({ description: 'Context mode: "shared" (default) or "isolated"' })
|
|
285
|
+
),
|
|
35
286
|
}),
|
|
36
287
|
execute: async (_id, params) => {
|
|
37
288
|
const tools = getTools();
|
|
@@ -39,10 +290,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
39
290
|
},
|
|
40
291
|
});
|
|
41
292
|
|
|
293
|
+
// ─── list_tasks ───────────────────────────────────────────────────────────
|
|
294
|
+
|
|
42
295
|
pi.registerTool({
|
|
43
296
|
name: 'list_tasks',
|
|
44
297
|
label: 'List Tasks',
|
|
45
|
-
description:
|
|
298
|
+
description:
|
|
299
|
+
'List all scheduled tasks with rich status: id, schedule, prompt, status, next run time (relative), last result, context mode.',
|
|
46
300
|
parameters: Type.Object({}),
|
|
47
301
|
execute: async () => {
|
|
48
302
|
const tools = getTools();
|
|
@@ -50,6 +304,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
50
304
|
},
|
|
51
305
|
});
|
|
52
306
|
|
|
307
|
+
// ─── cancel_task ─────────────────────────────────────────────────────────
|
|
308
|
+
|
|
53
309
|
pi.registerTool({
|
|
54
310
|
name: 'cancel_task',
|
|
55
311
|
label: 'Cancel Task',
|
|
@@ -62,4 +318,100 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
318
|
return tools.cancel_task(params);
|
|
63
319
|
},
|
|
64
320
|
});
|
|
321
|
+
|
|
322
|
+
// ─── pause_task ───────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
pi.registerTool({
|
|
325
|
+
name: 'pause_task',
|
|
326
|
+
label: 'Pause Task',
|
|
327
|
+
description: 'Pause a scheduled task. The task will not run until resumed.',
|
|
328
|
+
parameters: Type.Object({
|
|
329
|
+
task_id: Type.String({ description: 'Task ID to pause (from list_tasks)' }),
|
|
330
|
+
}),
|
|
331
|
+
execute: async (_id, params) => {
|
|
332
|
+
const tools = getTools();
|
|
333
|
+
return tools.pause_task(params);
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ─── resume_task ──────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
pi.registerTool({
|
|
340
|
+
name: 'resume_task',
|
|
341
|
+
label: 'Resume Task',
|
|
342
|
+
description: 'Resume a paused task. next_run is recomputed from now.',
|
|
343
|
+
parameters: Type.Object({
|
|
344
|
+
task_id: Type.String({ description: 'Task ID to resume (from list_tasks)' }),
|
|
345
|
+
}),
|
|
346
|
+
execute: async (_id, params) => {
|
|
347
|
+
const tools = getTools();
|
|
348
|
+
return tools.resume_task(params);
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ─── update_task ──────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
pi.registerTool({
|
|
355
|
+
name: 'update_task',
|
|
356
|
+
label: 'Update Task',
|
|
357
|
+
description:
|
|
358
|
+
"Update a task's prompt, schedule, or context_mode. If schedule changes, next_run is recomputed.",
|
|
359
|
+
parameters: Type.Object({
|
|
360
|
+
task_id: Type.String({ description: 'Task ID to update (from list_tasks)' }),
|
|
361
|
+
schedule: Type.Optional(Type.String({ description: 'New schedule string' })),
|
|
362
|
+
prompt: Type.Optional(Type.String({ description: 'New prompt' })),
|
|
363
|
+
context_mode: Type.Optional(
|
|
364
|
+
Type.String({ description: 'New context mode: "shared" or "isolated"' })
|
|
365
|
+
),
|
|
366
|
+
}),
|
|
367
|
+
execute: async (_id, params) => {
|
|
368
|
+
const tools = getTools();
|
|
369
|
+
return tools.update_task(params);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// ─── /tasks slash command ─────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
pi.registerCommand({
|
|
376
|
+
name: 'tasks',
|
|
377
|
+
description:
|
|
378
|
+
'Task management. Use "/tasks due" to list overdue tasks, or "/tasks" to list all active tasks.',
|
|
379
|
+
execute: async (args: string) => {
|
|
380
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
381
|
+
const { getDb } = require('../src/db/index.js') as typeof import('../src/db/index.js');
|
|
382
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
383
|
+
const { getTasksDue, formatTasksDue } = require('../src/scheduler.js') as typeof import('../src/scheduler.js');
|
|
384
|
+
|
|
385
|
+
const db = getDb();
|
|
386
|
+
const subCmd = args?.trim().toLowerCase();
|
|
387
|
+
|
|
388
|
+
if (subCmd === 'due') {
|
|
389
|
+
const due = getTasksDue(db);
|
|
390
|
+
return formatTasksDue(due as any);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// List all active tasks
|
|
394
|
+
const tasks = db
|
|
395
|
+
.prepare("SELECT * FROM tasks WHERE status='active' ORDER BY next_run ASC")
|
|
396
|
+
.all() as any[];
|
|
397
|
+
|
|
398
|
+
if (tasks.length === 0) {
|
|
399
|
+
return 'No active tasks.';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
const lines = tasks.map((t: any) => {
|
|
404
|
+
const nextRunMs = t.next_run ? new Date(t.next_run).getTime() : null;
|
|
405
|
+
const overdue = nextRunMs && nextRunMs <= now;
|
|
406
|
+
const rel = overdue
|
|
407
|
+
? 'OVERDUE'
|
|
408
|
+
: nextRunMs
|
|
409
|
+
? `in ${Math.round((nextRunMs - now) / 60_000)}m`
|
|
410
|
+
: 'unknown';
|
|
411
|
+
return `[${t.id}] ${(t.schedule_value || t.schedule).padEnd(20)} → ${t.prompt.slice(0, 40)} | next: ${rel}`;
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
return lines.join('\n');
|
|
415
|
+
},
|
|
416
|
+
});
|
|
65
417
|
}
|