@trevonistrevon/pi-loop 0.5.9 → 0.6.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.
- package/CHANGELOG.md +72 -0
- package/README.md +64 -5
- package/dist/api.d.ts +11 -0
- package/dist/api.js +10 -0
- package/dist/commands/loop-command.d.ts +9 -2
- package/dist/commands/loop-command.js +81 -51
- package/dist/commands/tasks-command.js +3 -3
- package/dist/coordinator.d.ts +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +89 -19
- package/dist/loop-format.d.ts +3 -0
- package/dist/loop-format.js +20 -0
- package/dist/loop-parse.d.ts +1 -0
- package/dist/loop-parse.js +59 -6
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.js +1 -1
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/reducer-backed-store.d.ts +1 -1
- package/dist/reducer-backed-store.js +1 -1
- package/dist/rpc/channels.d.ts +73 -0
- package/dist/rpc/channels.js +30 -0
- package/dist/rpc/cross-extension-rpc.d.ts +59 -0
- package/dist/rpc/cross-extension-rpc.js +117 -0
- package/dist/runtime/loop-events.d.ts +26 -0
- package/dist/runtime/loop-events.js +26 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/native-task-rpc.d.ts +22 -0
- package/dist/runtime/native-task-rpc.js +50 -0
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +19 -7
- package/dist/runtime/task-backlog-runtime.d.ts +5 -1
- package/dist/runtime/task-backlog-runtime.js +14 -7
- package/dist/runtime/task-mutations.d.ts +39 -0
- package/dist/runtime/task-mutations.js +68 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +40 -58
- package/dist/scheduler.js +25 -7
- package/dist/store.d.ts +9 -1
- package/dist/store.js +57 -0
- package/dist/tools/loop-tools.d.ts +8 -0
- package/dist/tools/loop-tools.js +105 -15
- package/dist/tools/monitor-tools.js +1 -3
- package/dist/tools/native-task-tools.d.ts +2 -6
- package/dist/tools/native-task-tools.js +25 -55
- package/dist/tools/tool-result.d.ts +8 -0
- package/dist/tools/tool-result.js +4 -0
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/package.json +15 -2
- package/src/api.ts +36 -0
- package/src/commands/loop-command.ts +100 -55
- package/src/commands/tasks-command.ts +3 -3
- package/src/coordinator.ts +1 -1
- package/src/index.ts +92 -19
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +62 -8
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +1 -1
- package/src/notification-reducer.ts +3 -0
- package/src/reducer-backed-store.ts +3 -3
- package/src/rpc/channels.ts +97 -0
- package/src/rpc/cross-extension-rpc.ts +152 -0
- package/src/runtime/loop-events.ts +60 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/native-task-rpc.ts +139 -0
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +25 -7
- package/src/runtime/task-mutations.ts +103 -0
- package/src/runtime/task-rpc.ts +48 -54
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +129 -16
- package/src/tools/monitor-tools.ts +1 -4
- package/src/tools/native-task-tools.ts +31 -52
- package/src/tools/tool-result.ts +4 -0
- package/src/trigger-system.ts +5 -5
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -83
- package/CONTRIBUTING.md +0 -69
- package/Dockerfile +0 -17
- package/benchmarks/experiment-sim.js +0 -30
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -33
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -176
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/commands/index.html +0 -131
- package/coverage/src/commands/loop-command.ts.html +0 -634
- package/coverage/src/commands/tasks-command.ts.html +0 -490
- package/coverage/src/coordinator.ts.html +0 -430
- package/coverage/src/goal-coordinator.ts.html +0 -259
- package/coverage/src/goal-reducer.ts.html +0 -982
- package/coverage/src/goal-store.ts.html +0 -742
- package/coverage/src/goal-verifier.ts.html +0 -808
- package/coverage/src/index.html +0 -386
- package/coverage/src/index.ts.html +0 -1111
- package/coverage/src/loop-parse.ts.html +0 -574
- package/coverage/src/loop-reducer.ts.html +0 -559
- package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
- package/coverage/src/monitor-manager.ts.html +0 -940
- package/coverage/src/monitor-reducer.ts.html +0 -583
- package/coverage/src/notification-reducer.ts.html +0 -550
- package/coverage/src/reducer-backed-store.ts.html +0 -589
- package/coverage/src/runtime/index.html +0 -206
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- package/coverage/src/runtime/notification-runtime.ts.html +0 -721
- package/coverage/src/runtime/scope.ts.html +0 -196
- package/coverage/src/runtime/session-runtime.ts.html +0 -679
- package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -574
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-rpc.ts.html +0 -541
- package/coverage/src/scheduler.ts.html +0 -400
- package/coverage/src/store.ts.html +0 -667
- package/coverage/src/task-backlog-coordinator.ts.html +0 -181
- package/coverage/src/task-reducer.ts.html +0 -550
- package/coverage/src/task-store.ts.html +0 -526
- package/coverage/src/tools/index.html +0 -146
- package/coverage/src/tools/loop-tools.ts.html +0 -1000
- package/coverage/src/tools/monitor-tools.ts.html +0 -547
- package/coverage/src/tools/native-task-tools.ts.html +0 -556
- package/coverage/src/trigger-system.ts.html +0 -547
- package/coverage/src/ui/index.html +0 -116
- package/coverage/src/ui/widget.ts.html +0 -292
- package/dist/goal-coordinator.d.ts +0 -22
- package/dist/goal-coordinator.js +0 -28
- package/dist/goal-reducer.d.ts +0 -98
- package/dist/goal-reducer.js +0 -190
- package/dist/goal-store.d.ts +0 -22
- package/dist/goal-store.js +0 -188
- package/dist/goal-types.d.ts +0 -82
- package/dist/goal-types.js +0 -1
- package/dist/goal-verifier.d.ts +0 -20
- package/dist/goal-verifier.js +0 -198
- package/docker-compose.yml +0 -12
- package/docs/architecture/goal-state-schema.md +0 -505
- package/docs/architecture/state-machine-migration.md +0 -546
- package/docs/architecture/state-machine-reducer-event-model.md +0 -823
- package/docs/architecture/state-machine-test-matrix.md +0 -249
- package/docs/architecture/state-machine-transition-map.md +0 -436
- package/release-please-config.json +0 -9
- package/src/goal-coordinator.ts +0 -58
- package/src/goal-reducer.ts +0 -299
- package/src/goal-store.ts +0 -219
- package/src/goal-types.ts +0 -104
- package/src/goal-verifier.ts +0 -241
- package/vitest.config.ts +0 -29
|
@@ -3,13 +3,21 @@ import type {
|
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
ExtensionUIContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { formatTrigger } from "../loop-format.js";
|
|
7
|
+
import { isValidCronExpression, parseInterval } from "../loop-parse.js";
|
|
8
|
+
import type { DynamicLoopState, LoopEntry, Trigger } from "../types.js";
|
|
8
9
|
|
|
9
10
|
interface LoopStoreLike {
|
|
10
11
|
list(): LoopEntry[];
|
|
11
12
|
get(id: string): LoopEntry | undefined;
|
|
12
|
-
create(trigger: Trigger, prompt: string, options
|
|
13
|
+
create(trigger: Trigger, prompt: string, options: {
|
|
14
|
+
recurring: boolean;
|
|
15
|
+
autoTask?: boolean;
|
|
16
|
+
taskBacklog?: boolean;
|
|
17
|
+
readOnly?: boolean;
|
|
18
|
+
maxFires?: number;
|
|
19
|
+
dynamic?: Partial<DynamicLoopState>;
|
|
20
|
+
}): LoopEntry;
|
|
13
21
|
pause(id: string): LoopEntry | undefined;
|
|
14
22
|
resume(id: string): LoopEntry | undefined;
|
|
15
23
|
delete(id: string): boolean;
|
|
@@ -27,33 +35,82 @@ export interface LoopCommandOptions {
|
|
|
27
35
|
updateWidget: () => void;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
type LoopCommandRoute =
|
|
39
|
+
| { type: "menu" }
|
|
40
|
+
| { type: "event"; source: string; prompt: string }
|
|
41
|
+
| { type: "cron"; interval: string; prompt: string; notifyEvery: boolean }
|
|
42
|
+
| { type: "invalid-cron"; interval: string }
|
|
43
|
+
| { type: "missing-interval-prompt" }
|
|
44
|
+
| { type: "dynamic"; goal: string };
|
|
45
|
+
|
|
46
|
+
function parseLoopCommandRoute(input: string): LoopCommandRoute {
|
|
47
|
+
const trimmed = input.trim();
|
|
48
|
+
if (!trimmed) return { type: "menu" };
|
|
49
|
+
|
|
50
|
+
const eventMatch = trimmed.match(/^(?:event|when)\s+(\S+)\s+(.+)$/i);
|
|
51
|
+
if (eventMatch?.[1] && eventMatch[2]) {
|
|
52
|
+
return { type: "event", source: eventMatch[1], prompt: eventMatch[2].trim() };
|
|
53
|
+
}
|
|
32
54
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
55
|
+
const parts = trimmed.split(/\s+/);
|
|
56
|
+
if (parts.length > 5) {
|
|
57
|
+
const interval = parts.slice(0, 5).join(" ");
|
|
58
|
+
const cronShaped = parts.slice(0, 5).every((part) => /^[\d*/,-]+$/.test(part));
|
|
59
|
+
if (cronShaped) {
|
|
60
|
+
if (!isValidCronExpression(interval)) return { type: "invalid-cron", interval };
|
|
61
|
+
return { type: "cron", interval, prompt: parts.slice(5).join(" "), notifyEvery: false };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
36
64
|
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
|
|
66
|
+
if (intervalMatch) {
|
|
67
|
+
const interval = intervalMatch[1] ?? intervalMatch[0];
|
|
68
|
+
const prompt = trimmed.slice(intervalMatch[0].length).trim();
|
|
69
|
+
if (!prompt) return { type: "missing-interval-prompt" };
|
|
70
|
+
return { type: "cron", interval, prompt, notifyEvery: true };
|
|
71
|
+
}
|
|
39
72
|
|
|
73
|
+
return { type: "dynamic", goal: trimmed };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
77
|
+
const { pi, getStore, getTriggerSystem, updateWidget } = options;
|
|
78
|
+
|
|
79
|
+
function createCronLoop(ui: ExtensionUIContext, interval: string, prompt: string, notifyEvery: boolean) {
|
|
80
|
+
let entry: LoopEntry | undefined;
|
|
40
81
|
try {
|
|
41
82
|
const parsed = parseInterval(interval);
|
|
42
83
|
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
43
|
-
|
|
84
|
+
entry = getStore().create(trigger, prompt, { recurring: true });
|
|
44
85
|
getTriggerSystem().add(entry);
|
|
45
86
|
updateWidget();
|
|
46
|
-
|
|
87
|
+
const cadence = notifyEvery ? `every ${parsed.description}` : parsed.description;
|
|
88
|
+
ui.notify(`Loop #${entry.id} created: ${cadence} — ${prompt.slice(0, 50)}`, "info");
|
|
47
89
|
} catch (err: unknown) {
|
|
90
|
+
if (entry) {
|
|
91
|
+
getTriggerSystem().remove(entry.id);
|
|
92
|
+
getStore().delete(entry.id);
|
|
93
|
+
updateWidget();
|
|
94
|
+
}
|
|
48
95
|
ui.notify((err as Error).message, "error");
|
|
49
96
|
}
|
|
50
97
|
}
|
|
51
98
|
|
|
52
|
-
async function
|
|
99
|
+
async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
|
|
100
|
+
const p = prompt || await ui.input("Prompt (what should the agent check?)");
|
|
101
|
+
if (!p) return;
|
|
102
|
+
|
|
103
|
+
const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
|
|
104
|
+
if (!interval) return;
|
|
105
|
+
|
|
106
|
+
createCronLoop(ui, interval, p, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function eventLoop(ui: ExtensionUIContext, prompt?: string, sourceOverride?: string) {
|
|
53
110
|
const p = prompt || await ui.input("Prompt");
|
|
54
111
|
if (!p) return;
|
|
55
112
|
|
|
56
|
-
const source = await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
|
|
113
|
+
const source = sourceOverride || await ui.input("Pi event source (e.g., tool_execution_start, before_agent_start)");
|
|
57
114
|
if (!source) return;
|
|
58
115
|
|
|
59
116
|
const trigger: Trigger = { type: "event", source };
|
|
@@ -63,6 +120,18 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
63
120
|
ui.notify(`Event loop #${entry.id} created: fires on "${source}"`, "info");
|
|
64
121
|
}
|
|
65
122
|
|
|
123
|
+
function dynamicLoop(ui: ExtensionUIContext, goal: string) {
|
|
124
|
+
const trigger: Trigger = { type: "dynamic" };
|
|
125
|
+
const entry = getStore().create(trigger, goal, {
|
|
126
|
+
recurring: true,
|
|
127
|
+
maxFires: 20,
|
|
128
|
+
dynamic: { goal, iteration: 0 },
|
|
129
|
+
});
|
|
130
|
+
getTriggerSystem().add(entry);
|
|
131
|
+
updateWidget();
|
|
132
|
+
ui.notify(`Dynamic loop #${entry.id} created — ${goal.slice(0, 50)}`, "info");
|
|
133
|
+
}
|
|
134
|
+
|
|
66
135
|
async function viewLoops(ui: ExtensionUIContext) {
|
|
67
136
|
const loops = getStore().list();
|
|
68
137
|
if (loops.length === 0) {
|
|
@@ -72,12 +141,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
72
141
|
|
|
73
142
|
const choices = loops.map((l) => {
|
|
74
143
|
const icon = l.status === "active" ? "*" : l.status === "paused" ? "-" : "x";
|
|
75
|
-
|
|
76
|
-
? `cron: ${l.trigger.schedule}`
|
|
77
|
-
: l.trigger.type === "event"
|
|
78
|
-
? `event: ${l.trigger.source}`
|
|
79
|
-
: `hybrid: ${l.trigger.cron}`;
|
|
80
|
-
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${triggerDesc})`;
|
|
144
|
+
return `${icon} #${l.id} [${l.status}] ${l.prompt.slice(0, 50)} (${formatTrigger(l.trigger, "command")})`;
|
|
81
145
|
});
|
|
82
146
|
choices.push("< Back");
|
|
83
147
|
|
|
@@ -85,7 +149,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
85
149
|
if (!selected || selected === "< Back") return;
|
|
86
150
|
|
|
87
151
|
const match = selected.match(/#(\d+)/);
|
|
88
|
-
if (match) {
|
|
152
|
+
if (match?.[1]) {
|
|
89
153
|
const entry = getStore().get(match[1]);
|
|
90
154
|
if (entry) {
|
|
91
155
|
const actions = ["x Delete"];
|
|
@@ -109,8 +173,9 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
109
173
|
updateWidget();
|
|
110
174
|
ui.notify(`Loop #${entry.id} paused`, "info");
|
|
111
175
|
} else if (action === "* Resume") {
|
|
112
|
-
getStore().resume(entry.id);
|
|
113
|
-
|
|
176
|
+
const resumed = getStore().resume(entry.id);
|
|
177
|
+
if (!resumed) return viewLoops(ui);
|
|
178
|
+
getTriggerSystem().add(resumed);
|
|
114
179
|
updateWidget();
|
|
115
180
|
ui.notify(`Loop #${entry.id} resumed`, "info");
|
|
116
181
|
}
|
|
@@ -127,12 +192,12 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
127
192
|
}
|
|
128
193
|
|
|
129
194
|
pi.registerCommand("loop", {
|
|
130
|
-
description: "Create a
|
|
195
|
+
description: "Create a loop. Use /loop [interval] [prompt] for scheduled loops, /loop event <source> <prompt> for event loops, or /loop <goal> for a dynamic goal loop.",
|
|
131
196
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
132
|
-
const trimmed = args.trim();
|
|
133
197
|
const ui = ctx.ui;
|
|
198
|
+
const route = parseLoopCommandRoute(args);
|
|
134
199
|
|
|
135
|
-
if (
|
|
200
|
+
if (route.type === "menu") {
|
|
136
201
|
const choice = await ui.select("Loop", [
|
|
137
202
|
"Create scheduled loop",
|
|
138
203
|
"Create event-triggered loop",
|
|
@@ -147,37 +212,17 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
|
|
|
147
212
|
return settings(ui);
|
|
148
213
|
}
|
|
149
214
|
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (!prompt) {
|
|
156
|
-
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
const parsed = parseInterval(interval);
|
|
162
|
-
const trigger: Trigger = { type: "cron", schedule: parsed.cron };
|
|
163
|
-
const entry = getStore().create(trigger, prompt, { recurring: true });
|
|
164
|
-
getTriggerSystem().add(entry);
|
|
165
|
-
updateWidget();
|
|
166
|
-
ui.notify(`Loop #${entry.id} created: every ${parsed.description} — ${prompt.slice(0, 50)}`, "info");
|
|
167
|
-
} catch (err: unknown) {
|
|
168
|
-
ui.notify((err as Error).message, "error");
|
|
169
|
-
}
|
|
215
|
+
if (route.type === "event") return eventLoop(ui, route.prompt, route.source);
|
|
216
|
+
if (route.type === "cron") return createCronLoop(ui, route.interval, route.prompt, route.notifyEvery);
|
|
217
|
+
if (route.type === "invalid-cron") {
|
|
218
|
+
ui.notify(`Invalid cron expression: ${route.interval}`, "error");
|
|
170
219
|
return;
|
|
171
220
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (!choice) return;
|
|
179
|
-
if (choice.startsWith("Event")) return eventLoop(ui, trimmed);
|
|
180
|
-
return scheduleLoop(ui, trimmed);
|
|
221
|
+
if (route.type === "missing-interval-prompt") {
|
|
222
|
+
ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
return dynamicLoop(ui, route.goal);
|
|
181
226
|
},
|
|
182
227
|
});
|
|
183
228
|
}
|
|
@@ -67,10 +67,10 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
|
|
|
67
67
|
return viewNativeTasks(ui);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
70
|
+
const taskId = selected.match(/#(\d+)/)?.[1];
|
|
71
|
+
if (!taskId) return viewNativeTasks(ui);
|
|
72
72
|
|
|
73
|
-
const task = taskStore.get(
|
|
73
|
+
const task = taskStore.get(taskId);
|
|
74
74
|
if (!task) return viewNativeTasks(ui);
|
|
75
75
|
|
|
76
76
|
const actions = ["x Delete"];
|
package/src/coordinator.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type ReducerSource =
|
|
|
8
8
|
| "coordinator"
|
|
9
9
|
| "system";
|
|
10
10
|
|
|
11
|
-
export type ReducerEntityType = "task" | "loop" | "monitor" | "notification"
|
|
11
|
+
export type ReducerEntityType = "task" | "loop" | "monitor" | "notification";
|
|
12
12
|
|
|
13
13
|
export interface ReducerEvent<TType extends string = string, TPayload = unknown> {
|
|
14
14
|
type: TType;
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Tools:
|
|
5
5
|
* LoopCreate — Create a scheduled or event-triggered re-wake loop
|
|
6
6
|
* LoopList — List all active loops with status and next-fire times
|
|
7
|
+
* LoopUpdate — Continue, pause, or complete a dynamic goal loop
|
|
7
8
|
* LoopDelete — Delete or pause a loop by ID
|
|
8
9
|
* MonitorCreate — Start a background command that streams output via pi events
|
|
9
10
|
* MonitorList — List running monitors
|
|
10
11
|
* MonitorStop — Stop a running monitor
|
|
11
12
|
*
|
|
12
13
|
* Commands:
|
|
13
|
-
* /loop — Schedule or manage re-wake loops
|
|
14
|
+
* /loop — Schedule or manage re-wake loops, including /loop <goal>
|
|
14
15
|
* /tasks — View or manage native fallback tasks when pi-tasks is absent
|
|
15
16
|
*/
|
|
16
17
|
|
|
@@ -20,6 +21,7 @@ import { registerTasksCommand } from "./commands/tasks-command.js";
|
|
|
20
21
|
import { atMaxFires } from "./loop-reducer.js";
|
|
21
22
|
import { MonitorManager } from "./monitor-manager.js";
|
|
22
23
|
import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
|
|
24
|
+
import { registerNativeTaskRpc } from "./runtime/native-task-rpc.js";
|
|
23
25
|
import {
|
|
24
26
|
createNotificationRuntime,
|
|
25
27
|
type LoopFireEvent,
|
|
@@ -63,16 +65,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
63
65
|
// call), so stale monitors don't linger in the count between turns.
|
|
64
66
|
monitorManager.setOnChange(() => widget.update());
|
|
65
67
|
widget.setTaskSummaryProvider(() => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
// Once pi-tasks owns the channels, any native store created during the
|
|
69
|
+
// detection window is a shadow — never surface it in the status line.
|
|
70
|
+
if (tasksAvailable || !nativeTaskStore) return { count: 0 };
|
|
71
|
+
|
|
72
|
+
let count = 0;
|
|
73
|
+
let activeSubject: string | undefined;
|
|
74
|
+
let nextSubject: string | undefined;
|
|
75
|
+
for (const task of nativeTaskStore.list()) {
|
|
76
|
+
if (task.status === "in_progress") {
|
|
77
|
+
count++;
|
|
78
|
+
activeSubject ??= task.subject;
|
|
79
|
+
} else if (task.status === "pending") {
|
|
80
|
+
count++;
|
|
81
|
+
nextSubject ??= task.subject;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const focus = activeSubject
|
|
86
|
+
? `active: ${activeSubject.slice(0, 50)}`
|
|
87
|
+
: nextSubject
|
|
88
|
+
? `next: ${nextSubject.slice(0, 50)}`
|
|
74
89
|
: undefined;
|
|
75
|
-
return { count
|
|
90
|
+
return { count, focusText: focus };
|
|
76
91
|
});
|
|
77
92
|
|
|
78
93
|
scheduler = new CronScheduler(store, onLoopFire);
|
|
@@ -80,9 +95,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
80
95
|
|
|
81
96
|
// ── pi-tasks integration ──
|
|
82
97
|
let tasksAvailable = false;
|
|
98
|
+
let tasksDetectionSettled = false;
|
|
83
99
|
let nativeTaskStore: TaskStore | undefined;
|
|
84
100
|
let nativeTasksRegistered = false;
|
|
85
101
|
|
|
102
|
+
function getOrCreateNativeTaskStore(): TaskStore | undefined {
|
|
103
|
+
// pi-tasks owns the task channels; don't create a shadow store.
|
|
104
|
+
if (tasksAvailable) return undefined;
|
|
105
|
+
if (!nativeTaskStore) {
|
|
106
|
+
nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
|
|
107
|
+
}
|
|
108
|
+
return nativeTaskStore;
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
const taskRuntime = createTaskRuntimeBridge({
|
|
87
112
|
pi,
|
|
88
113
|
isTasksAvailable: () => tasksAvailable,
|
|
@@ -97,6 +122,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
97
122
|
widget.update();
|
|
98
123
|
await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
|
|
99
124
|
},
|
|
125
|
+
onDetectionStarted: () => {
|
|
126
|
+
tasksDetectionSettled = false;
|
|
127
|
+
},
|
|
128
|
+
onDetectionSettled: () => {
|
|
129
|
+
tasksDetectionSettled = true;
|
|
130
|
+
},
|
|
131
|
+
debug,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The RPC server registers at init (not behind the 6s tool-fallback timer) so
|
|
135
|
+
// early cross-extension calls never race the timer; it stands down via
|
|
136
|
+
// isEnabled once an external pi-tasks is detected. checkTasksVersion ignores
|
|
137
|
+
// this server's own ping reply via its provider field. Mutating verbs stay
|
|
138
|
+
// silent until the detection probe settles, so a co-resident pi-tasks can
|
|
139
|
+
// never race the native server into creating divergent task state.
|
|
140
|
+
registerNativeTaskRpc({
|
|
141
|
+
pi,
|
|
142
|
+
getNativeTaskStore: getOrCreateNativeTaskStore,
|
|
143
|
+
isEnabled: () => !tasksAvailable,
|
|
144
|
+
isDetectionSettled: () => tasksDetectionSettled,
|
|
145
|
+
evaluateTaskBacklog: (taskStore, pendingCount) =>
|
|
146
|
+
evaluateTaskBacklog(taskStore, pendingCount),
|
|
147
|
+
updateWidget: () => {
|
|
148
|
+
widget.update();
|
|
149
|
+
},
|
|
100
150
|
debug,
|
|
101
151
|
});
|
|
102
152
|
|
|
@@ -161,6 +211,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
211
|
deleteLoop: (id) => {
|
|
162
212
|
store.delete(id);
|
|
163
213
|
},
|
|
214
|
+
recordDeletionTombstone: (id, tombstone) => {
|
|
215
|
+
store.recordDeletionTombstone(id, tombstone);
|
|
216
|
+
},
|
|
164
217
|
addTrigger: (entry) => {
|
|
165
218
|
triggerSystem.add(entry);
|
|
166
219
|
},
|
|
@@ -173,6 +226,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
173
226
|
hasPendingTasks: () => hasPendingTasks(),
|
|
174
227
|
bootstrapTaskLoop: (entry) => maybeBootstrapTaskLoop(entry),
|
|
175
228
|
triggerHasEventSource,
|
|
229
|
+
emitLoopAutodeleted: (payload) => {
|
|
230
|
+
pi.events.emit("loops:autodeleted", payload);
|
|
231
|
+
},
|
|
232
|
+
emitTaskBacklogEmpty: (payload) => {
|
|
233
|
+
pi.events.emit("tasks:backlog_empty", payload);
|
|
234
|
+
},
|
|
176
235
|
debug,
|
|
177
236
|
});
|
|
178
237
|
|
|
@@ -193,6 +252,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
193
252
|
}
|
|
194
253
|
store.fire(entry.id);
|
|
195
254
|
|
|
255
|
+
const firedAt = Date.now();
|
|
256
|
+
const firedEntry = entry.trigger.type === "dynamic"
|
|
257
|
+
? store.updateDynamic(entry.id, {
|
|
258
|
+
dynamic: {
|
|
259
|
+
awaitingUpdate: true,
|
|
260
|
+
nextWakeAt: undefined,
|
|
261
|
+
lastUpdatedAt: firedAt,
|
|
262
|
+
},
|
|
263
|
+
}) ?? entry
|
|
264
|
+
: entry;
|
|
265
|
+
|
|
196
266
|
if (entry.autoTask) {
|
|
197
267
|
autoCreateTask(entry).then((taskId) => {
|
|
198
268
|
if (taskId) debug(`loop #${entry.id} → task #${taskId}`);
|
|
@@ -200,13 +270,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
200
270
|
}
|
|
201
271
|
|
|
202
272
|
pi.events.emit("loop:fire", {
|
|
203
|
-
loopId:
|
|
204
|
-
prompt:
|
|
205
|
-
trigger:
|
|
206
|
-
timestamp:
|
|
207
|
-
readOnly:
|
|
208
|
-
recurring:
|
|
209
|
-
autoTask:
|
|
273
|
+
loopId: firedEntry.id,
|
|
274
|
+
prompt: firedEntry.prompt,
|
|
275
|
+
trigger: firedEntry.trigger,
|
|
276
|
+
timestamp: firedAt,
|
|
277
|
+
readOnly: firedEntry.readOnly,
|
|
278
|
+
recurring: firedEntry.recurring,
|
|
279
|
+
autoTask: firedEntry.autoTask,
|
|
280
|
+
dynamic: firedEntry.dynamic,
|
|
210
281
|
});
|
|
211
282
|
}
|
|
212
283
|
|
|
@@ -301,9 +372,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
301
372
|
|
|
302
373
|
// ── Native task tools (only when pi-tasks is absent) ──
|
|
303
374
|
|
|
375
|
+
// Only tool/command registration stays deferred: the tool names collide with
|
|
376
|
+
// pi-tasks and cannot be unregistered. The RPC server is already live (above).
|
|
304
377
|
const nativeTaskFallbackTimer = setTimeout(() => {
|
|
305
378
|
if (tasksAvailable || nativeTasksRegistered) return;
|
|
306
|
-
const taskStore =
|
|
379
|
+
const taskStore = getOrCreateNativeTaskStore();
|
|
380
|
+
if (!taskStore) return;
|
|
307
381
|
|
|
308
382
|
try {
|
|
309
383
|
registerTasksCommand({
|
|
@@ -331,7 +405,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
331
405
|
throw error;
|
|
332
406
|
}
|
|
333
407
|
|
|
334
|
-
nativeTaskStore = taskStore;
|
|
335
408
|
nativeTasksRegistered = true;
|
|
336
409
|
debug("native task tools registered (pi-tasks not detected)");
|
|
337
410
|
}, 6000);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Trigger } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type TriggerFormatStyle = "command" | "create" | "list" | "notification";
|
|
4
|
+
|
|
5
|
+
export function formatTrigger(trigger: Trigger | string, style: TriggerFormatStyle = "list"): string {
|
|
6
|
+
if (typeof trigger === "string") return trigger;
|
|
7
|
+
|
|
8
|
+
if (trigger.type === "cron") {
|
|
9
|
+
return style === "create" || style === "notification"
|
|
10
|
+
? `schedule: ${trigger.schedule}`
|
|
11
|
+
: `cron: ${trigger.schedule}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (trigger.type === "event") return `event: ${trigger.source}`;
|
|
15
|
+
|
|
16
|
+
if (trigger.type === "dynamic") return "dynamic";
|
|
17
|
+
|
|
18
|
+
if (style === "command") return `hybrid: ${trigger.cron}`;
|
|
19
|
+
if (style === "create") return `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
20
|
+
if (style === "notification") return "hybrid";
|
|
21
|
+
return `hybrid: ${trigger.cron} + ${trigger.event.source}`;
|
|
22
|
+
}
|
package/src/loop-parse.ts
CHANGED
|
@@ -23,8 +23,10 @@ const COMMON_INTERVALS: Record<number, string> = {
|
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
function roundToNearestCommon(seconds: number): { cron: string; description: string } {
|
|
26
|
+
// COMMON_INTERVALS is a non-empty const table, so keys[0] and
|
|
27
|
+
// COMMON_INTERVALS[best] below are true invariants, not runtime fallbacks.
|
|
26
28
|
const keys = Object.keys(COMMON_INTERVALS).map(Number).sort((a, b) => a - b);
|
|
27
|
-
let best = keys[0];
|
|
29
|
+
let best = keys[0] as number;
|
|
28
30
|
for (const k of keys) {
|
|
29
31
|
if (Math.abs(k - seconds) < Math.abs(best - seconds)) best = k;
|
|
30
32
|
}
|
|
@@ -43,7 +45,7 @@ function roundToNearestCommon(seconds: number): { cron: string; description: str
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
return { cron: COMMON_INTERVALS[best], description };
|
|
48
|
+
return { cron: COMMON_INTERVALS[best] as string, description };
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function isFullCron(expr: string): boolean {
|
|
@@ -51,17 +53,60 @@ function isFullCron(expr: string): boolean {
|
|
|
51
53
|
return parts.length === 5;
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
function parseCronNumber(input: string, min: number, max: number): number | undefined {
|
|
57
|
+
if (!/^\d+$/.test(input)) return undefined;
|
|
58
|
+
const value = Number.parseInt(input, 10);
|
|
59
|
+
return value >= min && value <= max ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isValidCronField(field: string, min: number, max: number): boolean {
|
|
63
|
+
return field.split(",").every((part) => {
|
|
64
|
+
const [base, step, extra] = part.split("/");
|
|
65
|
+
if (extra !== undefined || base === undefined) return false;
|
|
66
|
+
if (step !== undefined && (parseCronNumber(step, 1, max - min + 1) === undefined)) return false;
|
|
67
|
+
if (base === "*") return true;
|
|
68
|
+
|
|
69
|
+
const range = base.split("-");
|
|
70
|
+
if (range.length === 1) return step === undefined && parseCronNumber(base, min, max) !== undefined;
|
|
71
|
+
if (range.length !== 2) return false;
|
|
72
|
+
|
|
73
|
+
const start = parseCronNumber(range[0] ?? "", min, max);
|
|
74
|
+
const end = parseCronNumber(range[1] ?? "", min, max);
|
|
75
|
+
return start !== undefined && end !== undefined && start <= end;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isValidCronExpression(expr: string): boolean {
|
|
80
|
+
const fields = expr.trim().split(/\s+/);
|
|
81
|
+
if (fields.length !== 5) return false;
|
|
82
|
+
|
|
83
|
+
const ranges = [
|
|
84
|
+
[0, 59],
|
|
85
|
+
[0, 23],
|
|
86
|
+
[1, 31],
|
|
87
|
+
[1, 12],
|
|
88
|
+
[0, 6],
|
|
89
|
+
] as const;
|
|
90
|
+
return fields.every((field, index) => {
|
|
91
|
+
const range = ranges[index];
|
|
92
|
+
return range !== undefined && isValidCronField(field, range[0], range[1]);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
54
96
|
export function parseInterval(input: string): { cron: string; description: string } {
|
|
55
97
|
const trimmed = input.trim();
|
|
56
98
|
|
|
57
99
|
if (isFullCron(trimmed)) {
|
|
100
|
+
if (!isValidCronExpression(trimmed)) {
|
|
101
|
+
throw new Error(`Invalid cron expression: ${trimmed}`);
|
|
102
|
+
}
|
|
58
103
|
return { cron: trimmed, description: `cron: ${trimmed}` };
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
62
107
|
if (match) {
|
|
63
|
-
const value = parseInt(match[1], 10);
|
|
64
|
-
const unit = match[2].toLowerCase();
|
|
108
|
+
const value = parseInt(match[1] ?? "", 10);
|
|
109
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
65
110
|
const totalSec = value * (UNIT_TO_CRON[unit] ?? 60);
|
|
66
111
|
|
|
67
112
|
if (totalSec < 60) {
|
|
@@ -78,9 +123,18 @@ export function parseInterval(input: string): { cron: string; description: strin
|
|
|
78
123
|
|
|
79
124
|
export function cronToNextFire(cronExpr: string, fromDate: Date = new Date()): Date {
|
|
80
125
|
const parts = cronExpr.trim().split(/\s+/);
|
|
81
|
-
if (
|
|
126
|
+
if (!isValidCronExpression(cronExpr)) throw new Error(`Invalid cron expression: ${cronExpr}`);
|
|
82
127
|
|
|
83
128
|
const [minF, hourF, dayF, monthF, dowF] = parts;
|
|
129
|
+
if (
|
|
130
|
+
minF === undefined ||
|
|
131
|
+
hourF === undefined ||
|
|
132
|
+
dayF === undefined ||
|
|
133
|
+
monthF === undefined ||
|
|
134
|
+
dowF === undefined
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(`Invalid cron expression: ${cronExpr}`);
|
|
137
|
+
}
|
|
84
138
|
const now = new Date(fromDate);
|
|
85
139
|
now.setSeconds(0, 0);
|
|
86
140
|
|
|
@@ -107,7 +161,7 @@ function cronFieldMatches(field: string, value: number): boolean {
|
|
|
107
161
|
if (part === "*") return true;
|
|
108
162
|
|
|
109
163
|
if (part.includes("/")) {
|
|
110
|
-
const [range, stepStr] = part.split("/");
|
|
164
|
+
const [range = "", stepStr = ""] = part.split("/");
|
|
111
165
|
const step = parseInt(stepStr, 10);
|
|
112
166
|
let rangeMin: number;
|
|
113
167
|
let rangeMax: number;
|
|
@@ -116,7 +170,7 @@ function cronFieldMatches(field: string, value: number): boolean {
|
|
|
116
170
|
rangeMin = 0;
|
|
117
171
|
rangeMax = 59;
|
|
118
172
|
} else if (range.includes("-")) {
|
|
119
|
-
const [minS, maxS] = range.split("-");
|
|
173
|
+
const [minS = "", maxS = ""] = range.split("-");
|
|
120
174
|
rangeMin = parseInt(minS, 10);
|
|
121
175
|
rangeMax = parseInt(maxS, 10);
|
|
122
176
|
} else {
|
|
@@ -132,7 +186,7 @@ function cronFieldMatches(field: string, value: number): boolean {
|
|
|
132
186
|
}
|
|
133
187
|
|
|
134
188
|
if (part.includes("-")) {
|
|
135
|
-
const [minS, maxS] = part.split("-");
|
|
189
|
+
const [minS = "", maxS = ""] = part.split("-");
|
|
136
190
|
const min = parseInt(minS, 10);
|
|
137
191
|
const max = parseInt(maxS, 10);
|
|
138
192
|
if (value >= min && value <= max) return true;
|