@trevonistrevon/pi-loop 0.4.11 → 0.5.1

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.
Files changed (87) hide show
  1. package/README.md +20 -9
  2. package/dist/commands/loop-command.d.ts +22 -0
  3. package/dist/commands/loop-command.js +148 -0
  4. package/dist/commands/tasks-command.d.ts +15 -0
  5. package/dist/commands/tasks-command.js +117 -0
  6. package/dist/coordinator.d.ts +35 -0
  7. package/dist/coordinator.js +56 -0
  8. package/dist/goal-coordinator.d.ts +22 -0
  9. package/dist/goal-coordinator.js +28 -0
  10. package/dist/goal-reducer.d.ts +89 -0
  11. package/dist/goal-reducer.js +181 -0
  12. package/dist/goal-store.d.ts +31 -0
  13. package/dist/goal-store.js +298 -0
  14. package/dist/goal-types.d.ts +82 -0
  15. package/dist/goal-types.js +1 -0
  16. package/dist/goal-verifier.d.ts +20 -0
  17. package/dist/goal-verifier.js +198 -0
  18. package/dist/index.js +130 -1087
  19. package/dist/loop-reducer.d.ts +63 -0
  20. package/dist/loop-reducer.js +67 -0
  21. package/dist/monitor-completion-coordinator.d.ts +10 -0
  22. package/dist/monitor-completion-coordinator.js +13 -0
  23. package/dist/monitor-manager.d.ts +2 -0
  24. package/dist/monitor-manager.js +107 -29
  25. package/dist/monitor-reducer.d.ts +82 -0
  26. package/dist/monitor-reducer.js +69 -0
  27. package/dist/notification-reducer.d.ts +81 -0
  28. package/dist/notification-reducer.js +65 -0
  29. package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
  30. package/dist/runtime/monitor-ondone-runtime.js +49 -0
  31. package/dist/runtime/notification-runtime.d.ts +34 -0
  32. package/dist/runtime/notification-runtime.js +152 -0
  33. package/dist/runtime/scope.d.ts +8 -0
  34. package/dist/runtime/scope.js +33 -0
  35. package/dist/runtime/session-runtime.d.ts +39 -0
  36. package/dist/runtime/session-runtime.js +110 -0
  37. package/dist/runtime/task-backlog-runtime.d.ts +36 -0
  38. package/dist/runtime/task-backlog-runtime.js +105 -0
  39. package/dist/runtime/task-rpc.d.ts +19 -0
  40. package/dist/runtime/task-rpc.js +118 -0
  41. package/dist/store.d.ts +7 -4
  42. package/dist/store.js +129 -49
  43. package/dist/task-backlog-coordinator.d.ts +12 -0
  44. package/dist/task-backlog-coordinator.js +22 -0
  45. package/dist/task-reducer.d.ts +66 -0
  46. package/dist/task-reducer.js +76 -0
  47. package/dist/task-store.d.ts +8 -4
  48. package/dist/task-store.js +102 -33
  49. package/dist/tools/loop-tools.d.ts +41 -0
  50. package/dist/tools/loop-tools.js +241 -0
  51. package/dist/tools/monitor-tools.d.ts +25 -0
  52. package/dist/tools/monitor-tools.js +110 -0
  53. package/dist/tools/native-task-tools.d.ts +15 -0
  54. package/dist/tools/native-task-tools.js +127 -0
  55. package/docs/architecture/goal-state-schema.md +505 -0
  56. package/docs/architecture/state-machine-migration.md +546 -0
  57. package/docs/architecture/state-machine-reducer-event-model.md +823 -0
  58. package/docs/architecture/state-machine-test-matrix.md +249 -0
  59. package/docs/architecture/state-machine-transition-map.md +436 -0
  60. package/package.json +1 -1
  61. package/src/commands/loop-command.ts +184 -0
  62. package/src/commands/tasks-command.ts +135 -0
  63. package/src/coordinator.ts +115 -0
  64. package/src/goal-coordinator.ts +58 -0
  65. package/src/goal-reducer.ts +280 -0
  66. package/src/goal-store.ts +315 -0
  67. package/src/goal-types.ts +104 -0
  68. package/src/goal-verifier.ts +241 -0
  69. package/src/index.ts +134 -1147
  70. package/src/loop-reducer.ts +148 -0
  71. package/src/monitor-completion-coordinator.ts +24 -0
  72. package/src/monitor-manager.ts +115 -27
  73. package/src/monitor-reducer.ts +166 -0
  74. package/src/notification-reducer.ts +155 -0
  75. package/src/runtime/monitor-ondone-runtime.ts +75 -0
  76. package/src/runtime/notification-runtime.ts +212 -0
  77. package/src/runtime/scope.ts +37 -0
  78. package/src/runtime/session-runtime.ts +168 -0
  79. package/src/runtime/task-backlog-runtime.ts +163 -0
  80. package/src/runtime/task-rpc.ts +150 -0
  81. package/src/store.ts +132 -50
  82. package/src/task-backlog-coordinator.ts +32 -0
  83. package/src/task-reducer.ts +152 -0
  84. package/src/task-store.ts +103 -31
  85. package/src/tools/loop-tools.ts +304 -0
  86. package/src/tools/monitor-tools.ts +145 -0
  87. package/src/tools/native-task-tools.ts +144 -0
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import { reduceTaskState } from "./task-reducer.js";
4
5
  const TASKS_DIR = join(homedir(), ".pi", "tasks");
5
6
  const LOCK_RETRY_MS = 50;
6
7
  const LOCK_MAX_RETRIES = 100;
@@ -103,23 +104,31 @@ export class TaskStore {
103
104
  releaseLock(this.lockPath);
104
105
  }
105
106
  }
107
+ toReducerState() {
108
+ return {
109
+ nextId: this.nextId,
110
+ tasksById: Object.fromEntries(this.tasks.entries()),
111
+ };
112
+ }
113
+ applyReducerEvent(event) {
114
+ const result = reduceTaskState(this.toReducerState(), event);
115
+ this.nextId = result.state.nextId;
116
+ this.tasks = new Map(Object.entries(result.state.tasksById));
117
+ }
106
118
  create(subject, description, metadata) {
107
119
  return this.withLock(() => {
108
120
  if (this.tasks.size >= MAX_TASKS) {
109
121
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
110
122
  }
111
123
  const now = Date.now();
112
- const entry = {
113
- id: String(this.nextId++),
114
- subject,
115
- description,
116
- status: "pending",
117
- createdAt: now,
118
- updatedAt: now,
119
- metadata,
120
- };
121
- this.tasks.set(entry.id, entry);
122
- return entry;
124
+ this.applyReducerEvent({
125
+ type: "TASK_CREATED",
126
+ at: now,
127
+ source: "tool",
128
+ entityType: "task",
129
+ payload: { subject, description, metadata },
130
+ });
131
+ return this.tasks.get(String(this.nextId - 1));
123
132
  });
124
133
  }
125
134
  get(id) {
@@ -132,29 +141,88 @@ export class TaskStore {
132
141
  this.load();
133
142
  return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
134
143
  }
135
- update(id, fields) {
144
+ start(id) {
136
145
  return this.withLock(() => {
137
146
  const entry = this.tasks.get(id);
138
147
  if (!entry)
139
148
  return undefined;
140
- if (fields.status !== undefined) {
141
- entry.status = fields.status;
142
- if (fields.status === "completed")
143
- entry.completedAt = Date.now();
144
- }
145
- if (fields.subject !== undefined)
146
- entry.subject = fields.subject;
147
- if (fields.description !== undefined)
148
- entry.description = fields.description;
149
- entry.updatedAt = Date.now();
150
- return entry;
149
+ this.applyReducerEvent({
150
+ type: "TASK_STARTED",
151
+ at: Date.now(),
152
+ source: "tool",
153
+ entityType: "task",
154
+ entityId: id,
155
+ payload: { id },
156
+ });
157
+ return this.tasks.get(id);
158
+ });
159
+ }
160
+ complete(id) {
161
+ return this.withLock(() => {
162
+ const entry = this.tasks.get(id);
163
+ if (!entry)
164
+ return undefined;
165
+ this.applyReducerEvent({
166
+ type: "TASK_COMPLETED",
167
+ at: Date.now(),
168
+ source: "tool",
169
+ entityType: "task",
170
+ entityId: id,
171
+ payload: { id },
172
+ });
173
+ return this.tasks.get(id);
174
+ });
175
+ }
176
+ reopen(id) {
177
+ return this.withLock(() => {
178
+ const entry = this.tasks.get(id);
179
+ if (!entry)
180
+ return undefined;
181
+ this.applyReducerEvent({
182
+ type: "TASK_REOPENED",
183
+ at: Date.now(),
184
+ source: "tool",
185
+ entityType: "task",
186
+ entityId: id,
187
+ payload: { id },
188
+ });
189
+ return this.tasks.get(id);
190
+ });
191
+ }
192
+ updateDetails(id, fields) {
193
+ return this.withLock(() => {
194
+ const entry = this.tasks.get(id);
195
+ if (!entry)
196
+ return undefined;
197
+ if (fields.subject === undefined && fields.description === undefined)
198
+ return entry;
199
+ this.applyReducerEvent({
200
+ type: "TASK_UPDATED",
201
+ at: Date.now(),
202
+ source: "tool",
203
+ entityType: "task",
204
+ entityId: id,
205
+ payload: {
206
+ id,
207
+ subject: fields.subject,
208
+ description: fields.description,
209
+ },
210
+ });
211
+ return this.tasks.get(id);
151
212
  });
152
213
  }
153
214
  delete(id) {
154
215
  return this.withLock(() => {
155
216
  if (!this.tasks.has(id))
156
217
  return false;
157
- this.tasks.delete(id);
218
+ this.applyReducerEvent({
219
+ type: "TASK_DELETED",
220
+ at: Date.now(),
221
+ source: "tool",
222
+ entityType: "task",
223
+ entityId: id,
224
+ payload: { id },
225
+ });
158
226
  return true;
159
227
  });
160
228
  }
@@ -166,16 +234,17 @@ export class TaskStore {
166
234
  }
167
235
  return count;
168
236
  }
169
- sweepCompleted() {
237
+ pruneCompleted() {
170
238
  return this.withLock(() => {
171
- let count = 0;
172
- for (const [id, entry] of this.tasks) {
173
- if (entry.status === "completed") {
174
- this.tasks.delete(id);
175
- count++;
176
- }
177
- }
178
- return count;
239
+ const before = this.tasks.size;
240
+ this.applyReducerEvent({
241
+ type: "TASKS_PRUNED",
242
+ at: Date.now(),
243
+ source: "system",
244
+ entityType: "task",
245
+ payload: { reason: "manual" },
246
+ });
247
+ return before - this.tasks.size;
179
248
  });
180
249
  }
181
250
  }
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { LoopEntry, Trigger } from "../types.js";
3
+ interface LoopStoreLike {
4
+ list(): LoopEntry[];
5
+ get(id: string): LoopEntry | undefined;
6
+ create(trigger: Trigger, prompt: string, opts: {
7
+ recurring: boolean;
8
+ autoTask?: boolean;
9
+ taskBacklog?: boolean;
10
+ readOnly?: boolean;
11
+ maxFires?: number;
12
+ }): LoopEntry;
13
+ pause(id: string): LoopEntry | undefined;
14
+ delete(id: string): boolean;
15
+ }
16
+ interface TriggerSystemLike {
17
+ add(entry: LoopEntry): void;
18
+ remove(id: string): void;
19
+ }
20
+ interface SchedulerLike {
21
+ nextFire(id: string): number | undefined;
22
+ }
23
+ interface MonitorLike {
24
+ id: string;
25
+ status: string;
26
+ }
27
+ interface MonitorManagerLike {
28
+ get(id: string): MonitorLike | undefined;
29
+ }
30
+ export interface LoopToolsOptions {
31
+ pi: ExtensionAPI;
32
+ getStore: () => LoopStoreLike;
33
+ getTriggerSystem: () => TriggerSystemLike;
34
+ getScheduler: () => SchedulerLike;
35
+ getMonitorManager: () => MonitorManagerLike;
36
+ updateWidget: () => void;
37
+ maybeBootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
38
+ isTaskSystemReady: () => boolean;
39
+ }
40
+ export declare function registerLoopTools(options: LoopToolsOptions): void;
41
+ export {};
@@ -0,0 +1,241 @@
1
+ import { Type } from "typebox";
2
+ import { parseInterval } from "../loop-parse.js";
3
+ function textResult(msg) {
4
+ return { content: [{ type: "text", text: msg }], details: undefined };
5
+ }
6
+ function validateTrigger(trigger) {
7
+ if (trigger.type === "cron") {
8
+ const parts = trigger.schedule.trim().split(/\s+/);
9
+ if (parts.length !== 5) {
10
+ return `Invalid cron trigger. Expected 5 fields, got ${parts.length}: "${trigger.schedule}". Use formats like "5m", "1h", "0 9 * * 1-5", or set triggerType to "event" for event sources.`;
11
+ }
12
+ }
13
+ else if (trigger.type === "event") {
14
+ if (!trigger.source || trigger.source.trim().length === 0) {
15
+ return "Invalid event trigger. Event source must be non-empty (e.g., \"tool_execution_start\").";
16
+ }
17
+ }
18
+ else if (trigger.type === "hybrid") {
19
+ const cronParts = trigger.cron.trim().split(/\s+/);
20
+ if (cronParts.length !== 5) {
21
+ return `Invalid hybrid trigger. Cron part must have 5 fields, got ${cronParts.length}: "${trigger.cron}".`;
22
+ }
23
+ if (!trigger.event.source || trigger.event.source.trim().length === 0) {
24
+ return "Invalid hybrid trigger. Event source must be non-empty (e.g., \"tool_execution_start\").";
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+ function inferTriggerType(input) {
30
+ if (input.includes("hybrid") || (input.includes("cron") && input.includes("event")))
31
+ return "hybrid";
32
+ if (/^\d+\s*[smhd]$/i.test(input.trim()))
33
+ return "cron";
34
+ if (/^(\*|\d+)/.test(input.trim()) && input.trim().split(/\s+/).length === 5)
35
+ return "cron";
36
+ return "event";
37
+ }
38
+ function formatRemaining(ms) {
39
+ if (ms < 60000)
40
+ return `${Math.round(ms / 1000)}s`;
41
+ if (ms < 3600000)
42
+ return `${Math.round(ms / 60000)}m`;
43
+ return `${Math.round(ms / 3600000)}h`;
44
+ }
45
+ export function registerLoopTools(options) {
46
+ const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, } = options;
47
+ pi.registerTool({
48
+ name: "LoopCreate",
49
+ label: "LoopCreate",
50
+ description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
51
+
52
+ Use this tool whenever the user asks to:
53
+ - "create a loop" to check something periodically
54
+ - "run every X seconds/minutes/hours"
55
+ - "remind me to check..."
56
+ - "watch for..." or "when X happens, do Y"
57
+ - "schedule a recurring check"
58
+ - set up a periodic monitor or poller
59
+ - has a task list with open items — create a loop to work through tasks automatically
60
+
61
+ DO NOT use raw Bash loops (for/sleep/while). Use LoopCreate instead — it integrates with the session lifecycle, survives across turns, and the scheduler handles timing.
62
+
63
+ ## When NOT to Use
64
+
65
+ Skip this tool when the task is a one-off check (just do it directly) or when the user wants a purely reactive hook.
66
+
67
+ ## Trigger Types
68
+
69
+ - **cron**: time-based. "30s" (rounded to 1m), "5m", "2h", "1d", or full cron like "0 9 * * 1-5"
70
+ - **event**: fires on pi events like "tool_execution_start", "before_agent_start"
71
+ - **hybrid**: both cron + event with debounce
72
+
73
+ ## Parameters
74
+
75
+ - **trigger**: interval like "30s", "5m", "2h", event source, or hybrid spec
76
+ - **prompt**: what to do when the loop fires (e.g., "check if the build passed")
77
+ - **recurring**: repeat or fire once (default: true)
78
+ - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
79
+ - **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
80
+ - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
81
+ - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
82
+ promptGuidelines: [
83
+ "Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
84
+ "## Choosing trigger type",
85
+ "Prefer event triggers over cron when possible — they fire exactly when needed instead of polling.",
86
+ "Use event triggers for: tool completion ('tool_execution_end'), task creation ('tasks:created'), monitor completion ('monitor:done').",
87
+ "Use cron triggers only when: the user explicitly asks for a time interval, or there's no relevant pi event to subscribe to.",
88
+ "Hybrid triggers (cron + event) give you both: event-driven responsiveness with a cron safety-net fallback.",
89
+ "## Choosing an interval",
90
+ "Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
91
+ "## maxFires — prevent infinite token burn",
92
+ "Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
93
+ "When a loop fires and finds nothing to do, call LoopDelete on its own ID to stop it — don't keep polling.",
94
+ "## readOnly mode",
95
+ "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
96
+ "## Task-driven workflows",
97
+ "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
98
+ "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
99
+ "Set taskBacklog: true for task-worker loops that process the existing pending queue. Task-backlog loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
100
+ "When no tasks are pending, the loop should stop itself or skip the wake entirely — no tokens burned on empty polls.",
101
+ "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
102
+ ],
103
+ parameters: Type.Object({
104
+ trigger: Type.String({ description: "Cron expression (e.g., '5m', '1h', '0 9 * * 1-5'), event source (e.g., 'tool_execution_start'), or JSON hybrid spec" }),
105
+ prompt: Type.String({ description: "Prompt to run when the loop fires" }),
106
+ recurring: Type.Optional(Type.Boolean({ description: "Whether loop repeats (default: true)", default: true })),
107
+ autoTask: Type.Optional(Type.Boolean({ description: "Auto-create pi-tasks task on fire", default: false })),
108
+ taskBacklog: Type.Optional(Type.Boolean({ description: "Mark as a task-backlog worker loop that auto-deletes when pending tasks reach zero", default: false })),
109
+ triggerType: Type.Optional(Type.String({ description: "cron, event, or hybrid (inferred from trigger string if omitted)", enum: ["cron", "event", "hybrid"] })),
110
+ debounceMs: Type.Optional(Type.Number({ description: "Debounce for hybrid triggers (default: 30000)", default: 30000 })),
111
+ readOnly: Type.Optional(Type.Boolean({ description: "Restrict the agent to read-only tools when this loop fires (default: false)", default: false })),
112
+ maxFires: Type.Optional(Type.Number({ description: "Auto-stop after N fires. Prevents infinite token burn on polling loops." })),
113
+ }),
114
+ async execute(_toolCallId, params) {
115
+ const { trigger: triggerInput, prompt, recurring, autoTask, taskBacklog, triggerType, debounceMs, readOnly, maxFires } = params;
116
+ let trigger;
117
+ const inferred = triggerType ?? inferTriggerType(triggerInput);
118
+ if (inferred === "cron") {
119
+ const parsed = parseInterval(triggerInput);
120
+ trigger = { type: "cron", schedule: parsed.cron };
121
+ }
122
+ else if (inferred === "event") {
123
+ trigger = { type: "event", source: triggerInput };
124
+ }
125
+ else {
126
+ const cronPart = triggerInput.match(/cron:?\s*(\S+)/)?.[1] || triggerInput;
127
+ const eventPart = triggerInput.match(/event:?\s*(\S+)/)?.[1];
128
+ const parsed = parseInterval(cronPart);
129
+ trigger = {
130
+ type: "hybrid",
131
+ cron: parsed.cron,
132
+ event: { source: eventPart || "tool_execution_start" },
133
+ debounceMs: debounceMs ?? 30000,
134
+ };
135
+ }
136
+ const validationError = validateTrigger(trigger);
137
+ if (validationError)
138
+ return Promise.resolve(textResult(validationError));
139
+ const entry = getStore().create(trigger, prompt, {
140
+ recurring: recurring ?? (inferred !== "event"),
141
+ autoTask,
142
+ taskBacklog,
143
+ readOnly,
144
+ maxFires,
145
+ });
146
+ getTriggerSystem().add(entry);
147
+ if (trigger.type === "event" && trigger.source === "monitor:done" && trigger.filter) {
148
+ try {
149
+ const filterObj = JSON.parse(trigger.filter);
150
+ const monitorId = filterObj.monitorId;
151
+ if (monitorId) {
152
+ const monitor = getMonitorManager().get(monitorId);
153
+ if (monitor && monitor.status !== "running") {
154
+ getTriggerSystem().remove(entry.id);
155
+ getStore().delete(entry.id);
156
+ }
157
+ }
158
+ }
159
+ catch {
160
+ // ignore malformed monitor filter; loop remains registered
161
+ }
162
+ }
163
+ const bootstrapped = await maybeBootstrapTaskLoop(entry);
164
+ updateWidget();
165
+ const triggerDesc = trigger.type === "cron"
166
+ ? `schedule: ${trigger.schedule}`
167
+ : trigger.type === "event"
168
+ ? `event: ${trigger.source}`
169
+ : `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
170
+ return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
171
+ `Trigger: ${triggerDesc}\n` +
172
+ `Recurring: ${entry.recurring}\n` +
173
+ (entry.autoTask ? "Auto-task: enabled\n" : "") +
174
+ (entry.taskBacklog ? "Task-backlog: enabled\n" : "") +
175
+ (bootstrapped ? "Bootstrap: queued initial wake for existing pending tasks\n" : "") +
176
+ (isTaskSystemReady() ? "" : "(task system not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available)\n") +
177
+ `ID: ${entry.id} (use LoopDelete to cancel)`));
178
+ },
179
+ });
180
+ pi.registerTool({
181
+ name: "LoopList",
182
+ label: "LoopList",
183
+ description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
184
+
185
+ Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
186
+ parameters: Type.Object({}),
187
+ execute() {
188
+ const loops = getStore().list();
189
+ if (loops.length === 0)
190
+ return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
191
+ const lines = [];
192
+ for (const entry of loops) {
193
+ const triggerDesc = entry.trigger.type === "cron"
194
+ ? `cron: ${entry.trigger.schedule}`
195
+ : entry.trigger.type === "event"
196
+ ? `event: ${entry.trigger.source}`
197
+ : `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
198
+ const nextFire = entry.trigger.type !== "event" ? getScheduler().nextFire(entry.id) : undefined;
199
+ const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
200
+ let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
201
+ line += ` (${triggerDesc})`;
202
+ if (nextFire) {
203
+ const remaining = Math.max(0, nextFire - Date.now());
204
+ line += ` next: ${formatRemaining(remaining)}`;
205
+ }
206
+ if (entry.autoTask)
207
+ line += " [auto-task]";
208
+ lines.push(line);
209
+ }
210
+ return Promise.resolve(textResult(lines.join("\n")));
211
+ },
212
+ });
213
+ pi.registerTool({
214
+ name: "LoopDelete",
215
+ label: "LoopDelete",
216
+ description: `Delete or pause a loop by its ID.
217
+
218
+ Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.`,
219
+ parameters: Type.Object({
220
+ id: Type.String({ description: "Loop ID to delete or pause" }),
221
+ action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
222
+ }),
223
+ execute(_toolCallId, params) {
224
+ const { id, action } = params;
225
+ if (action === "pause") {
226
+ const entry = getStore().pause(id);
227
+ if (!entry)
228
+ return Promise.resolve(textResult(`Loop #${id} not found`));
229
+ getTriggerSystem().remove(id);
230
+ updateWidget();
231
+ return Promise.resolve(textResult(`Loop #${id} paused`));
232
+ }
233
+ getTriggerSystem().remove(id);
234
+ const deleted = getStore().delete(id);
235
+ updateWidget();
236
+ if (deleted)
237
+ return Promise.resolve(textResult(`Loop #${id} deleted`));
238
+ return Promise.resolve(textResult(`Loop #${id} not found`));
239
+ },
240
+ });
241
+ }
@@ -0,0 +1,25 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { LoopEntry, MonitorEntry, Trigger } from "../types.js";
3
+ interface MonitorManagerLike {
4
+ list(): MonitorEntry[];
5
+ create(command: string, description?: string, timeout?: number): MonitorEntry;
6
+ stop(id: string): Promise<boolean>;
7
+ }
8
+ interface LoopStoreLike {
9
+ create(trigger: Trigger, prompt: string, opts: {
10
+ recurring: boolean;
11
+ autoTask?: boolean;
12
+ taskBacklog?: boolean;
13
+ readOnly?: boolean;
14
+ maxFires?: number;
15
+ }): LoopEntry;
16
+ }
17
+ export interface MonitorToolsOptions {
18
+ pi: ExtensionAPI;
19
+ getStore: () => LoopStoreLike;
20
+ getMonitorManager: () => MonitorManagerLike;
21
+ updateWidget: () => void;
22
+ handleMonitorDoneLoop: (doneLoop: LoopEntry, monitorId: string) => void;
23
+ }
24
+ export declare function registerMonitorTools(options: MonitorToolsOptions): void;
25
+ export {};
@@ -0,0 +1,110 @@
1
+ import { Type } from "typebox";
2
+ function textResult(msg) {
3
+ return { content: [{ type: "text", text: msg }], details: undefined };
4
+ }
5
+ function formatRemaining(ms) {
6
+ if (ms < 60000)
7
+ return `${Math.round(ms / 1000)}s`;
8
+ if (ms < 3600000)
9
+ return `${Math.round(ms / 60000)}m`;
10
+ return `${Math.round(ms / 3600000)}h`;
11
+ }
12
+ export function registerMonitorTools(options) {
13
+ const { pi, getStore, getMonitorManager, updateWidget, handleMonitorDoneLoop } = options;
14
+ pi.registerTool({
15
+ name: "MonitorCreate",
16
+ label: "MonitorCreate",
17
+ description: `Run a shell command in the background and get notified when it finishes. The core tool for async/parallel work.
18
+
19
+ Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
20
+
21
+ If you pass onDone with a prompt, the monitor auto-creates a one-shot completion loop — you get a completion wake with the exit code and output line count. No need to poll or create a separate loop.
22
+
23
+ DO NOT use raw Bash while/sleep/for loops to watch something. DO NOT run slow commands inline that could be offloaded. Use MonitorCreate to run work in parallel while you continue.
24
+
25
+ ## When to Use
26
+
27
+ Default to MonitorCreate for any long-running or background work:\n- Watch a CI/CD build (hut, gh, curl polling) while you work on something else\n- Run experiments, benchmarks, or training scripts in parallel\n- Tail a log or poll an API endpoint\n- Fire off a slow curl/fetch and check the result later\n- Run any script or command you don't need to wait on inline
28
+
29
+ ## Events emitted
30
+
31
+ - "monitor:output" — { monitorId, line, timestamp } for each output line\n- "monitor:done" — { monitorId, exitCode, outputLines } on clean exit\n- "monitor:error" — { monitorId, error } on failure
32
+
33
+ ## onDone — auto-notify on completion
34
+
35
+ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fires when the process exits. The completion wake includes the exit code and output line count.\n\nExample: MonitorCreate command="python train.py" onDone="Check training results and report best loss"\nExample: MonitorCreate command="hut builds show 1769753" onDone="Analyze the build result and report status"`,
36
+ promptGuidelines: [
37
+ "Default to MonitorCreate for any long-running or background command — releases the agent to keep working on other tasks in parallel.",
38
+ "When the user asks to monitor CI builds, watch a build, check a remote job, or run an experiment, use MonitorCreate instead of inline bash/curl/wait.",
39
+ "Use onDone to auto-notify when a background command finishes — the agent will pick up the results automatically.",
40
+ ],
41
+ parameters: Type.Object({
42
+ command: Type.String({ description: "Shell command to run in background" }),
43
+ description: Type.Optional(Type.String({ description: "Human-readable description" })),
44
+ timeout: Type.Optional(Type.Number({ description: "Auto-stop after N ms (default: 300000, 0 = no timeout)", default: 300000 })),
45
+ onDone: Type.Optional(Type.String({ description: "Prompt to run when the monitor completes. Auto-creates a one-shot completion wake — no need for a separate LoopCreate." })),
46
+ }),
47
+ execute(_toolCallId, params) {
48
+ if (getMonitorManager().list().filter((m) => m.status === "running").length >= 25) {
49
+ return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
50
+ }
51
+ const entry = getMonitorManager().create(params.command, params.description, params.timeout);
52
+ updateWidget();
53
+ let onDoneMsg = "";
54
+ if (params.onDone) {
55
+ const doneTrigger = { type: "event", source: "monitor:done", filter: JSON.stringify({ monitorId: entry.id }) };
56
+ const doneLoop = getStore().create(doneTrigger, params.onDone, { recurring: false });
57
+ handleMonitorDoneLoop(doneLoop, entry.id);
58
+ onDoneMsg = `\nonDone loop #${doneLoop.id}: fires when monitor completes — no polling needed`;
59
+ }
60
+ return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
61
+ `Output stream: monitor:output (monitorId: ${entry.id})\n` +
62
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`));
63
+ },
64
+ });
65
+ pi.registerTool({
66
+ name: "MonitorList",
67
+ label: "MonitorList",
68
+ description: "List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.",
69
+ parameters: Type.Object({}),
70
+ execute() {
71
+ const monitors = getMonitorManager().list();
72
+ if (monitors.length === 0)
73
+ return Promise.resolve(textResult("No monitors running."));
74
+ const lines = [];
75
+ for (const m of monitors) {
76
+ const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "!!";
77
+ const age = Date.now() - m.startedAt;
78
+ const ageStr = formatRemaining(age);
79
+ let line = `${icon} #${m.id} [${m.status}] ${m.command.slice(0, 60)} — ${m.outputLines} lines (${ageStr})`;
80
+ if (m.exitCode !== undefined)
81
+ line += ` exit=${m.exitCode}`;
82
+ lines.push(line);
83
+ if (m.status !== "running" && m.outputBuffer.length > 0) {
84
+ const tail = m.outputBuffer.slice(-5);
85
+ for (const out of tail) {
86
+ lines.push(` | ${out.slice(0, 100)}`);
87
+ }
88
+ }
89
+ }
90
+ return Promise.resolve(textResult(lines.join("\n")));
91
+ },
92
+ });
93
+ pi.registerTool({
94
+ name: "MonitorStop",
95
+ label: "MonitorStop",
96
+ description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
97
+
98
+ Use MonitorList to find the monitor ID, then stop it with this tool.`,
99
+ parameters: Type.Object({
100
+ monitorId: Type.String({ description: "Monitor ID to stop" }),
101
+ }),
102
+ async execute(_toolCallId, params) {
103
+ const stopped = await getMonitorManager().stop(params.monitorId);
104
+ updateWidget();
105
+ if (stopped)
106
+ return textResult(`Monitor #${params.monitorId} stopped`);
107
+ return textResult(`Monitor #${params.monitorId} not found or not running`);
108
+ },
109
+ });
110
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { TaskStore } from "../task-store.js";
3
+ export interface TaskBacklogResult {
4
+ created: boolean;
5
+ entry?: {
6
+ id: string;
7
+ };
8
+ }
9
+ export interface NativeTaskToolsOptions {
10
+ pi: ExtensionAPI;
11
+ taskStore: TaskStore;
12
+ evaluateTaskBacklog: (taskStore: TaskStore, pendingCount: number) => Promise<TaskBacklogResult>;
13
+ updateWidget: () => void;
14
+ }
15
+ export declare function registerNativeTaskTools(options: NativeTaskToolsOptions): void;