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 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 setup
16
- reeboot start
11
+ reeboot
17
12
  ```
18
13
 
19
- The wizard will ask for your LLM provider, API key, model, and which channels to enable. After that, `reeboot start` runs the agent — open the WebChat URL it prints, or scan the WhatsApp QR code.
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 tools backed by SQLite.
5
- * Uses getDb() for DB access and integrates with the Scheduler singleton
6
- * (injected via pi's extension context or resolved from the global registry).
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: 'Schedule a recurring task. Provide a cron expression, a prompt, and optionally a contextId.',
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({ description: 'Cron expression (e.g. "0 9 * * 1-5" for weekdays at 9am)' }),
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(Type.String({ description: 'Context to run in (default: main)' })),
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: 'List all scheduled tasks with their id, schedule, prompt, contextId, enabled status and last run time.',
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
  }