@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.
Files changed (152) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +64 -5
  3. package/dist/api.d.ts +11 -0
  4. package/dist/api.js +10 -0
  5. package/dist/commands/loop-command.d.ts +9 -2
  6. package/dist/commands/loop-command.js +81 -51
  7. package/dist/commands/tasks-command.js +3 -3
  8. package/dist/coordinator.d.ts +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +89 -19
  11. package/dist/loop-format.d.ts +3 -0
  12. package/dist/loop-format.js +20 -0
  13. package/dist/loop-parse.d.ts +1 -0
  14. package/dist/loop-parse.js +59 -6
  15. package/dist/loop-reducer.d.ts +13 -1
  16. package/dist/loop-reducer.js +26 -0
  17. package/dist/monitor-manager.js +1 -1
  18. package/dist/notification-reducer.d.ts +2 -0
  19. package/dist/reducer-backed-store.d.ts +1 -1
  20. package/dist/reducer-backed-store.js +1 -1
  21. package/dist/rpc/channels.d.ts +73 -0
  22. package/dist/rpc/channels.js +30 -0
  23. package/dist/rpc/cross-extension-rpc.d.ts +59 -0
  24. package/dist/rpc/cross-extension-rpc.js +117 -0
  25. package/dist/runtime/loop-events.d.ts +26 -0
  26. package/dist/runtime/loop-events.js +26 -0
  27. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  28. package/dist/runtime/native-task-rpc.d.ts +22 -0
  29. package/dist/runtime/native-task-rpc.js +50 -0
  30. package/dist/runtime/notification-runtime.d.ts +2 -1
  31. package/dist/runtime/notification-runtime.js +19 -7
  32. package/dist/runtime/task-backlog-runtime.d.ts +5 -1
  33. package/dist/runtime/task-backlog-runtime.js +14 -7
  34. package/dist/runtime/task-mutations.d.ts +39 -0
  35. package/dist/runtime/task-mutations.js +68 -0
  36. package/dist/runtime/task-rpc.d.ts +4 -0
  37. package/dist/runtime/task-rpc.js +40 -58
  38. package/dist/scheduler.js +25 -7
  39. package/dist/store.d.ts +9 -1
  40. package/dist/store.js +57 -0
  41. package/dist/tools/loop-tools.d.ts +8 -0
  42. package/dist/tools/loop-tools.js +105 -15
  43. package/dist/tools/monitor-tools.js +1 -3
  44. package/dist/tools/native-task-tools.d.ts +2 -6
  45. package/dist/tools/native-task-tools.js +25 -55
  46. package/dist/tools/tool-result.d.ts +8 -0
  47. package/dist/tools/tool-result.js +4 -0
  48. package/dist/trigger-system.js +1 -1
  49. package/dist/types.d.ts +24 -1
  50. package/package.json +15 -2
  51. package/src/api.ts +36 -0
  52. package/src/commands/loop-command.ts +100 -55
  53. package/src/commands/tasks-command.ts +3 -3
  54. package/src/coordinator.ts +1 -1
  55. package/src/index.ts +92 -19
  56. package/src/loop-format.ts +22 -0
  57. package/src/loop-parse.ts +62 -8
  58. package/src/loop-reducer.ts +41 -1
  59. package/src/monitor-manager.ts +1 -1
  60. package/src/notification-reducer.ts +3 -0
  61. package/src/reducer-backed-store.ts +3 -3
  62. package/src/rpc/channels.ts +97 -0
  63. package/src/rpc/cross-extension-rpc.ts +152 -0
  64. package/src/runtime/loop-events.ts +60 -0
  65. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  66. package/src/runtime/native-task-rpc.ts +139 -0
  67. package/src/runtime/notification-runtime.ts +21 -8
  68. package/src/runtime/task-backlog-runtime.ts +25 -7
  69. package/src/runtime/task-mutations.ts +103 -0
  70. package/src/runtime/task-rpc.ts +48 -54
  71. package/src/scheduler.ts +25 -8
  72. package/src/store.ts +59 -2
  73. package/src/tools/loop-tools.ts +129 -16
  74. package/src/tools/monitor-tools.ts +1 -4
  75. package/src/tools/native-task-tools.ts +31 -52
  76. package/src/tools/tool-result.ts +4 -0
  77. package/src/trigger-system.ts +5 -5
  78. package/src/types.ts +29 -1
  79. package/.release-please-manifest.json +0 -3
  80. package/AGENTS.md +0 -83
  81. package/CONTRIBUTING.md +0 -69
  82. package/Dockerfile +0 -17
  83. package/benchmarks/experiment-sim.js +0 -30
  84. package/coverage/base.css +0 -224
  85. package/coverage/block-navigation.js +0 -87
  86. package/coverage/coverage-final.json +0 -33
  87. package/coverage/favicon.png +0 -0
  88. package/coverage/index.html +0 -176
  89. package/coverage/prettify.css +0 -1
  90. package/coverage/prettify.js +0 -2
  91. package/coverage/sort-arrow-sprite.png +0 -0
  92. package/coverage/sorter.js +0 -210
  93. package/coverage/src/commands/index.html +0 -131
  94. package/coverage/src/commands/loop-command.ts.html +0 -634
  95. package/coverage/src/commands/tasks-command.ts.html +0 -490
  96. package/coverage/src/coordinator.ts.html +0 -430
  97. package/coverage/src/goal-coordinator.ts.html +0 -259
  98. package/coverage/src/goal-reducer.ts.html +0 -982
  99. package/coverage/src/goal-store.ts.html +0 -742
  100. package/coverage/src/goal-verifier.ts.html +0 -808
  101. package/coverage/src/index.html +0 -386
  102. package/coverage/src/index.ts.html +0 -1111
  103. package/coverage/src/loop-parse.ts.html +0 -574
  104. package/coverage/src/loop-reducer.ts.html +0 -559
  105. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  106. package/coverage/src/monitor-manager.ts.html +0 -940
  107. package/coverage/src/monitor-reducer.ts.html +0 -583
  108. package/coverage/src/notification-reducer.ts.html +0 -550
  109. package/coverage/src/reducer-backed-store.ts.html +0 -589
  110. package/coverage/src/runtime/index.html +0 -206
  111. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  112. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  113. package/coverage/src/runtime/scope.ts.html +0 -196
  114. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  115. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -574
  116. package/coverage/src/runtime/task-events.ts.html +0 -208
  117. package/coverage/src/runtime/task-rpc.ts.html +0 -541
  118. package/coverage/src/scheduler.ts.html +0 -400
  119. package/coverage/src/store.ts.html +0 -667
  120. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  121. package/coverage/src/task-reducer.ts.html +0 -550
  122. package/coverage/src/task-store.ts.html +0 -526
  123. package/coverage/src/tools/index.html +0 -146
  124. package/coverage/src/tools/loop-tools.ts.html +0 -1000
  125. package/coverage/src/tools/monitor-tools.ts.html +0 -547
  126. package/coverage/src/tools/native-task-tools.ts.html +0 -556
  127. package/coverage/src/trigger-system.ts.html +0 -547
  128. package/coverage/src/ui/index.html +0 -116
  129. package/coverage/src/ui/widget.ts.html +0 -292
  130. package/dist/goal-coordinator.d.ts +0 -22
  131. package/dist/goal-coordinator.js +0 -28
  132. package/dist/goal-reducer.d.ts +0 -98
  133. package/dist/goal-reducer.js +0 -190
  134. package/dist/goal-store.d.ts +0 -22
  135. package/dist/goal-store.js +0 -188
  136. package/dist/goal-types.d.ts +0 -82
  137. package/dist/goal-types.js +0 -1
  138. package/dist/goal-verifier.d.ts +0 -20
  139. package/dist/goal-verifier.js +0 -198
  140. package/docker-compose.yml +0 -12
  141. package/docs/architecture/goal-state-schema.md +0 -505
  142. package/docs/architecture/state-machine-migration.md +0 -546
  143. package/docs/architecture/state-machine-reducer-event-model.md +0 -823
  144. package/docs/architecture/state-machine-test-matrix.md +0 -249
  145. package/docs/architecture/state-machine-transition-map.md +0 -436
  146. package/release-please-config.json +0 -9
  147. package/src/goal-coordinator.ts +0 -58
  148. package/src/goal-reducer.ts +0 -299
  149. package/src/goal-store.ts +0 -219
  150. package/src/goal-types.ts +0 -104
  151. package/src/goal-verifier.ts +0 -241
  152. 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 { parseInterval } from "../loop-parse.js";
7
- import type { LoopEntry, Trigger } from "../types.js";
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?: Partial<LoopEntry>): LoopEntry;
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
- export function registerLoopCommand(options: LoopCommandOptions): void {
31
- const { pi, getStore, getTriggerSystem, updateWidget } = options;
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
- async function scheduleLoop(ui: ExtensionUIContext, prompt?: string) {
34
- const p = prompt || await ui.input("Prompt (what should the agent check?)");
35
- if (!p) return;
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
- const interval = await ui.input("Interval (e.g., 5m, 2h, 1d)");
38
- if (!interval) return;
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
- const entry = getStore().create(trigger, p, { recurring: true });
84
+ entry = getStore().create(trigger, prompt, { recurring: true });
44
85
  getTriggerSystem().add(entry);
45
86
  updateWidget();
46
- ui.notify(`Loop #${entry.id} created: every ${parsed.description}`, "info");
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 eventLoop(ui: ExtensionUIContext, prompt?: string) {
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
- const triggerDesc = l.trigger.type === "cron"
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
- getTriggerSystem().add(entry);
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 repeating scheduled task: /loop [interval] [prompt]. E.g., /loop 5m check the deploy, /loop 30s am I still here",
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 (!trimmed) {
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
- const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
151
- if (intervalMatch) {
152
- const interval = intervalMatch[1];
153
- const prompt = trimmed.slice(intervalMatch[0].length).trim();
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
- const choice = await ui.select("Loop mode", [
174
- `Scheduled: "${trimmed.slice(0, 50)}"`,
175
- `Event-triggered: "${trimmed.slice(0, 50)}"`,
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 match = selected.match(/#(\d+)/);
71
- if (!match) return viewNativeTasks(ui);
70
+ const taskId = selected.match(/#(\d+)/)?.[1];
71
+ if (!taskId) return viewNativeTasks(ui);
72
72
 
73
- const task = taskStore.get(match[1]);
73
+ const task = taskStore.get(taskId);
74
74
  if (!task) return viewNativeTasks(ui);
75
75
 
76
76
  const actions = ["x Delete"];
@@ -8,7 +8,7 @@ export type ReducerSource =
8
8
  | "coordinator"
9
9
  | "system";
10
10
 
11
- export type ReducerEntityType = "task" | "loop" | "monitor" | "notification" | "goal";
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: /loop [interval] [prompt]
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
- if (!nativeTaskStore) return { count: 0 };
67
- const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
68
- const active = tasks.find(t => t.status === "in_progress");
69
- const next = tasks.find(t => t.status === "pending");
70
- const focus = active
71
- ? `active: ${active.subject.slice(0, 50)}`
72
- : next
73
- ? `next: ${next.subject.slice(0, 50)}`
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: tasks.length, focusText: focus };
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: entry.id,
204
- prompt: entry.prompt,
205
- trigger: entry.trigger,
206
- timestamp: Date.now(),
207
- readOnly: entry.readOnly,
208
- recurring: entry.recurring,
209
- autoTask: entry.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 = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
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 (parts.length !== 5) throw new Error(`Invalid cron expression: ${cronExpr}`);
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;