@trevonistrevon/pi-loop 0.5.6 → 0.5.8

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 (53) hide show
  1. package/coverage/coverage-final.json +27 -27
  2. package/coverage/index.html +39 -39
  3. package/coverage/src/commands/index.html +1 -1
  4. package/coverage/src/commands/loop-command.ts.html +3 -3
  5. package/coverage/src/commands/tasks-command.ts.html +3 -3
  6. package/coverage/src/coordinator.ts.html +20 -20
  7. package/coverage/src/goal-coordinator.ts.html +1 -1
  8. package/coverage/src/goal-reducer.ts.html +1 -1
  9. package/coverage/src/goal-store.ts.html +1 -1
  10. package/coverage/src/goal-verifier.ts.html +1 -1
  11. package/coverage/src/index.html +33 -33
  12. package/coverage/src/index.ts.html +71 -71
  13. package/coverage/src/loop-parse.ts.html +62 -62
  14. package/coverage/src/loop-reducer.ts.html +28 -28
  15. package/coverage/src/monitor-completion-coordinator.ts.html +3 -3
  16. package/coverage/src/monitor-manager.ts.html +76 -70
  17. package/coverage/src/monitor-reducer.ts.html +28 -28
  18. package/coverage/src/notification-reducer.ts.html +15 -15
  19. package/coverage/src/reducer-backed-store.ts.html +54 -54
  20. package/coverage/src/runtime/index.html +53 -53
  21. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +39 -39
  22. package/coverage/src/runtime/notification-runtime.ts.html +42 -42
  23. package/coverage/src/runtime/scope.ts.html +34 -34
  24. package/coverage/src/runtime/session-runtime.ts.html +141 -51
  25. package/coverage/src/runtime/task-backlog-runtime.ts.html +48 -48
  26. package/coverage/src/runtime/task-rpc.ts.html +81 -81
  27. package/coverage/src/scheduler.ts.html +41 -41
  28. package/coverage/src/store.ts.html +26 -26
  29. package/coverage/src/task-backlog-coordinator.ts.html +8 -8
  30. package/coverage/src/task-reducer.ts.html +39 -39
  31. package/coverage/src/task-store.ts.html +55 -55
  32. package/coverage/src/tools/index.html +43 -43
  33. package/coverage/src/tools/loop-tools.ts.html +93 -93
  34. package/coverage/src/tools/monitor-tools.ts.html +53 -53
  35. package/coverage/src/tools/native-task-tools.ts.html +66 -66
  36. package/coverage/src/trigger-system.ts.html +11 -11
  37. package/coverage/src/ui/index.html +1 -1
  38. package/coverage/src/ui/widget.ts.html +22 -22
  39. package/dist/commands/tasks-command.js +12 -9
  40. package/dist/monitor-manager.js +3 -1
  41. package/dist/runtime/session-runtime.js +29 -0
  42. package/dist/runtime/task-events.d.ts +15 -0
  43. package/dist/runtime/task-events.js +13 -0
  44. package/dist/runtime/task-rpc.js +2 -0
  45. package/dist/tools/native-task-tools.js +20 -9
  46. package/package.json +1 -1
  47. package/src/commands/tasks-command.ts +9 -9
  48. package/src/monitor-manager.ts +3 -1
  49. package/src/runtime/session-runtime.ts +30 -0
  50. package/src/runtime/task-events.ts +41 -0
  51. package/src/runtime/task-rpc.ts +2 -0
  52. package/src/tools/native-task-tools.ts +16 -9
  53. package/vitest.config.ts +7 -7
@@ -1,4 +1,5 @@
1
1
  import { Type } from "typebox";
2
+ import { emitNativeTaskEvent } from "../runtime/task-events.js";
2
3
  function textResult(msg) {
3
4
  return { content: [{ type: "text", text: msg }], details: undefined };
4
5
  }
@@ -30,12 +31,7 @@ Fields:
30
31
  }),
31
32
  async execute(_toolCallId, params) {
32
33
  const entry = taskStore.create(params.subject, params.description);
33
- pi.events.emit("tasks:created", {
34
- taskId: entry.id,
35
- subject: entry.subject,
36
- description: entry.description,
37
- status: entry.status,
38
- });
34
+ emitNativeTaskEvent(pi, "tasks:created", entry);
39
35
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
40
36
  updateWidget();
41
37
  const autoLoopMsg = backlog.created && backlog.entry
@@ -91,16 +87,28 @@ Parameters: id (required), status, subject, description`,
91
87
  let entry = taskStore.get(id);
92
88
  if (!entry)
93
89
  return Promise.resolve(textResult(`Task #${id} not found`));
94
- if (status === "in_progress")
90
+ const previousStatus = entry.status;
91
+ if (status === "in_progress") {
95
92
  entry = taskStore.start(id);
96
- else if (status === "completed")
93
+ if (entry)
94
+ emitNativeTaskEvent(pi, "tasks:started", entry, previousStatus);
95
+ }
96
+ else if (status === "completed") {
97
97
  entry = taskStore.complete(id);
98
- else if (status === "pending")
98
+ if (entry)
99
+ emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
100
+ }
101
+ else if (status === "pending") {
99
102
  entry = taskStore.reopen(id);
103
+ if (entry)
104
+ emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
105
+ }
100
106
  if (!entry)
101
107
  return Promise.resolve(textResult(`Task #${id} not found`));
102
108
  if (subject !== undefined || description !== undefined) {
103
109
  entry = taskStore.updateDetails(id, { subject, description });
110
+ if (entry)
111
+ emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
104
112
  }
105
113
  if (!entry)
106
114
  return Promise.resolve(textResult(`Task #${id} not found`));
@@ -121,9 +129,12 @@ Parameters: id (required), status, subject, description`,
121
129
  id: Type.String({ description: "Task ID to delete" }),
122
130
  }),
123
131
  async execute(_toolCallId, params) {
132
+ const existing = taskStore.get(params.id);
124
133
  const deleted = taskStore.delete(params.id);
125
134
  updateWidget();
126
135
  if (deleted) {
136
+ if (existing)
137
+ emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
127
138
  await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
128
139
  return Promise.resolve(textResult(`Task #${params.id} deleted`));
129
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { emitNativeTaskEvent } from "../runtime/task-events.js";
2
3
  import { TaskStore } from "../task-store.js";
3
4
  import type { TaskEntry } from "../task-types.js";
4
5
 
@@ -18,12 +19,7 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
18
19
  const { pi, getNativeTaskStore, evaluateTaskBacklog, updateWidget } = options;
19
20
 
20
21
  async function emitCreated(entry: TaskEntry) {
21
- pi.events.emit("tasks:created", {
22
- taskId: entry.id,
23
- subject: entry.subject,
24
- description: entry.description,
25
- status: entry.status,
26
- });
22
+ emitNativeTaskEvent(pi, "tasks:created", entry);
27
23
  const taskStore = getNativeTaskStore();
28
24
  if (!taskStore) return { created: false } satisfies TaskBacklogResult;
29
25
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
@@ -94,15 +90,19 @@ export function registerTasksCommand(options: TasksCommandOptions): void {
94
90
 
95
91
  if (action === "x Delete") {
96
92
  taskStore.delete(task.id);
93
+ emitNativeTaskEvent(pi, "tasks:deleted", task, task.status);
97
94
  ui.notify(`Task #${task.id} deleted`, "info");
98
95
  } else if (action === "> Start") {
99
- taskStore.start(task.id);
96
+ const next = taskStore.start(task.id);
97
+ if (next) emitNativeTaskEvent(pi, "tasks:started", next, task.status);
100
98
  ui.notify(`Task #${task.id} started`, "info");
101
99
  } else if (action === "ok Complete") {
102
- taskStore.complete(task.id);
100
+ const next = taskStore.complete(task.id);
101
+ if (next) emitNativeTaskEvent(pi, "tasks:completed", next, task.status);
103
102
  ui.notify(`Task #${task.id} completed`, "info");
104
103
  } else if (action === "* Return to pending" || action === "* Reopen") {
105
- taskStore.reopen(task.id);
104
+ const next = taskStore.reopen(task.id);
105
+ if (next) emitNativeTaskEvent(pi, "tasks:reopened", next, task.status);
106
106
  ui.notify(`Task #${task.id} reopened`, "info");
107
107
  }
108
108
 
@@ -58,7 +58,8 @@ export class MonitorManager {
58
58
  // finish() and stop() so every terminal status is pruned consistently — a
59
59
  // stopped monitor that is never pruned lingers in list() and the widget count.
60
60
  private schedulePrune(id: string): void {
61
- setTimeout(() => {
61
+ // unref so a pending prune never keeps a one-shot (`pi -p`) process alive.
62
+ const timer = setTimeout(() => {
62
63
  this.applyReducerEvent({
63
64
  type: "MONITOR_PRUNED",
64
65
  at: Date.now(),
@@ -68,6 +69,7 @@ export class MonitorManager {
68
69
  payload: { id },
69
70
  });
70
71
  }, 30000);
72
+ timer.unref?.();
71
73
  }
72
74
 
73
75
  create(command: string, description?: string, timeout = 300000): MonitorEntry {
@@ -7,6 +7,10 @@ export interface SessionSwitchEvent {
7
7
  reason?: string;
8
8
  }
9
9
 
10
+ // Wall-clock cadence for the idle heartbeat that pumps the scheduler. Cron is
11
+ // minute-granular, so 30s gives sub-minute wake latency while idle.
12
+ const HEARTBEAT_MS = 30_000;
13
+
10
14
  export interface SessionRuntimeOptions {
11
15
  pi: ExtensionAPI;
12
16
  getLoopScope: () => LoopScope;
@@ -48,6 +52,27 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
48
52
 
49
53
  let storeUpgraded = false;
50
54
  let persistedShown = false;
55
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
56
+
57
+ // The CronScheduler is pump-driven; without this heartbeat it only advances at
58
+ // turn boundaries (turn_start/agent_end), so a loop whose fire time elapses
59
+ // while the agent is idle would never fire and never re-wake the agent. The
60
+ // timer is unref'd so it never keeps a one-shot (`pi -p`) process alive.
61
+ function ensureHeartbeat(): void {
62
+ if (heartbeatTimer) return;
63
+ heartbeatTimer = setInterval(() => {
64
+ // Swallow pump failures so a transient error never surfaces as an
65
+ // unhandled rejection; the next tick retries.
66
+ void pumpLoops().catch(() => {});
67
+ }, HEARTBEAT_MS);
68
+ heartbeatTimer.unref?.();
69
+ }
70
+
71
+ function stopHeartbeat(): void {
72
+ if (!heartbeatTimer) return;
73
+ clearInterval(heartbeatTimer);
74
+ heartbeatTimer = undefined;
75
+ }
51
76
 
52
77
  function upgradeStoreIfNeeded(ctx: ExtensionContext) {
53
78
  if (storeUpgraded) return;
@@ -66,6 +91,7 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
66
91
  getStore().clearExpired();
67
92
  getStore().expireEventLoops(sessionStartedAt);
68
93
  getTriggerSystem().start();
94
+ ensureHeartbeat();
69
95
  widget.update();
70
96
  }
71
97
  }
@@ -89,6 +115,7 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
89
115
  setSessionId(ctx.sessionManager.getSessionId());
90
116
  widget.setUICtx(ctx.ui);
91
117
  upgradeStoreIfNeeded(ctx);
118
+ ensureHeartbeat();
92
119
  widget.update();
93
120
  await pumpLoops();
94
121
  });
@@ -97,6 +124,7 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
97
124
  setLatestCtx(ctx);
98
125
  widget.setUICtx(ctx.ui);
99
126
  upgradeStoreIfNeeded(ctx);
127
+ ensureHeartbeat();
100
128
  showPersistedLoops();
101
129
  widget.update();
102
130
  });
@@ -123,6 +151,7 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
123
151
  });
124
152
 
125
153
  pi.on("session_shutdown", async () => {
154
+ stopHeartbeat();
126
155
  notificationRuntime.clear("session_shutdown");
127
156
  });
128
157
 
@@ -130,6 +159,7 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
130
159
  setLatestCtx(ctx);
131
160
  widget.setUICtx(ctx.ui);
132
161
  getTriggerSystem().stop();
162
+ stopHeartbeat();
133
163
  notificationRuntime.clear("session_switch");
134
164
  setSessionId(undefined);
135
165
 
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskEntry, TaskStatus } from "../task-types.js";
3
+
4
+ export type NativeTaskEventName =
5
+ | "tasks:created"
6
+ | "tasks:started"
7
+ | "tasks:completed"
8
+ | "tasks:reopened"
9
+ | "tasks:updated"
10
+ | "tasks:deleted";
11
+
12
+ export interface NativeTaskEventPayload {
13
+ taskId: string;
14
+ subject: string;
15
+ description: string;
16
+ status: TaskStatus;
17
+ previousStatus?: TaskStatus;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ completedAt?: number;
21
+ metadata?: Record<string, unknown>;
22
+ }
23
+
24
+ export function emitNativeTaskEvent(
25
+ pi: ExtensionAPI,
26
+ name: NativeTaskEventName,
27
+ entry: TaskEntry,
28
+ previousStatus?: TaskStatus,
29
+ ): void {
30
+ pi.events.emit(name, {
31
+ taskId: entry.id,
32
+ subject: entry.subject,
33
+ description: entry.description,
34
+ status: entry.status,
35
+ previousStatus,
36
+ createdAt: entry.createdAt,
37
+ updatedAt: entry.updatedAt,
38
+ completedAt: entry.completedAt,
39
+ metadata: entry.metadata,
40
+ } satisfies NativeTaskEventPayload);
41
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import type { TaskStore } from "../task-store.js";
4
4
  import type { LoopEntry } from "../types.js";
5
+ import { emitNativeTaskEvent } from "./task-events.js";
5
6
 
6
7
  export interface TaskRuntimeBridgeOptions {
7
8
  pi: ExtensionAPI;
@@ -82,6 +83,7 @@ export function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): Task
82
83
  `Auto-created from loop #${entry.id}`,
83
84
  { loopId: entry.id, trigger: entry.trigger },
84
85
  );
86
+ emitNativeTaskEvent(pi, "tasks:created", task);
85
87
  onNativeTaskCreated?.(nativeTaskStore);
86
88
  return task.id;
87
89
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
+ import { emitNativeTaskEvent } from "../runtime/task-events.js";
3
4
  import { TaskStore } from "../task-store.js";
4
5
 
5
6
  export interface TaskBacklogResult {
@@ -47,12 +48,7 @@ Fields:
47
48
  }),
48
49
  async execute(_toolCallId, params) {
49
50
  const entry = taskStore.create(params.subject, params.description);
50
- pi.events.emit("tasks:created", {
51
- taskId: entry.id,
52
- subject: entry.subject,
53
- description: entry.description,
54
- status: entry.status,
55
- });
51
+ emitNativeTaskEvent(pi, "tasks:created", entry);
56
52
  const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
57
53
  updateWidget();
58
54
 
@@ -111,13 +107,22 @@ Parameters: id (required), status, subject, description`,
111
107
  let entry = taskStore.get(id);
112
108
  if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
113
109
 
114
- if (status === "in_progress") entry = taskStore.start(id);
115
- else if (status === "completed") entry = taskStore.complete(id);
116
- else if (status === "pending") entry = taskStore.reopen(id);
110
+ const previousStatus = entry.status;
111
+ if (status === "in_progress") {
112
+ entry = taskStore.start(id);
113
+ if (entry) emitNativeTaskEvent(pi, "tasks:started", entry, previousStatus);
114
+ } else if (status === "completed") {
115
+ entry = taskStore.complete(id);
116
+ if (entry) emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
117
+ } else if (status === "pending") {
118
+ entry = taskStore.reopen(id);
119
+ if (entry) emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
120
+ }
117
121
 
118
122
  if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
119
123
  if (subject !== undefined || description !== undefined) {
120
124
  entry = taskStore.updateDetails(id, { subject, description });
125
+ if (entry) emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
121
126
  }
122
127
  if (!entry) return Promise.resolve(textResult(`Task #${id} not found`));
123
128
  updateWidget();
@@ -138,9 +143,11 @@ Parameters: id (required), status, subject, description`,
138
143
  id: Type.String({ description: "Task ID to delete" }),
139
144
  }),
140
145
  async execute(_toolCallId, params) {
146
+ const existing = taskStore.get(params.id);
141
147
  const deleted = taskStore.delete(params.id);
142
148
  updateWidget();
143
149
  if (deleted) {
150
+ if (existing) emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
144
151
  await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
145
152
  return Promise.resolve(textResult(`Task #${params.id} deleted`));
146
153
  }
package/vitest.config.ts CHANGED
@@ -11,14 +11,14 @@ export default defineConfig({
11
11
  include: ["src/**/*.ts"],
12
12
  // Type-only modules and test helpers carry no executable logic worth gating.
13
13
  exclude: ["src/**/*-types.ts", "src/types.ts"],
14
- // Floors set just below current actuals (stmts 80%, branches 69.5%,
15
- // funcs 92.5%, lines 82.9%) to catch regressions. Ratchet up as the
16
- // runtime/ and tools/ suites land in Phase 4.
14
+ // Floors set just below current actuals (stmts 84%, branches 75%,
15
+ // funcs 95%, lines 86%) to catch regressions. Raised in Phase 4 after the
16
+ // runtime/ + tools/ suites landed.
17
17
  thresholds: {
18
- statements: 78,
19
- branches: 67,
20
- functions: 90,
21
- lines: 80,
18
+ statements: 82,
19
+ branches: 73,
20
+ functions: 94,
21
+ lines: 84,
22
22
  },
23
23
  },
24
24
  },