@trevonistrevon/pi-loop 0.5.7 → 0.5.9

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 (61) hide show
  1. package/.release-please-manifest.json +3 -0
  2. package/Dockerfile +17 -0
  3. package/coverage/coverage-final.json +28 -27
  4. package/coverage/index.html +47 -47
  5. package/coverage/src/commands/index.html +15 -15
  6. package/coverage/src/commands/loop-command.ts.html +3 -3
  7. package/coverage/src/commands/tasks-command.ts.html +23 -23
  8. package/coverage/src/coordinator.ts.html +20 -20
  9. package/coverage/src/goal-coordinator.ts.html +1 -1
  10. package/coverage/src/goal-reducer.ts.html +1 -1
  11. package/coverage/src/goal-store.ts.html +1 -1
  12. package/coverage/src/goal-verifier.ts.html +1 -1
  13. package/coverage/src/index.html +42 -42
  14. package/coverage/src/index.ts.html +122 -74
  15. package/coverage/src/loop-parse.ts.html +61 -61
  16. package/coverage/src/loop-reducer.ts.html +25 -25
  17. package/coverage/src/monitor-completion-coordinator.ts.html +3 -3
  18. package/coverage/src/monitor-manager.ts.html +73 -67
  19. package/coverage/src/monitor-reducer.ts.html +27 -27
  20. package/coverage/src/notification-reducer.ts.html +7 -7
  21. package/coverage/src/reducer-backed-store.ts.html +53 -53
  22. package/coverage/src/runtime/index.html +66 -51
  23. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +39 -39
  24. package/coverage/src/runtime/notification-runtime.ts.html +14 -14
  25. package/coverage/src/runtime/scope.ts.html +34 -34
  26. package/coverage/src/runtime/session-runtime.ts.html +63 -57
  27. package/coverage/src/runtime/task-backlog-runtime.ts.html +48 -48
  28. package/coverage/src/runtime/task-events.ts.html +208 -0
  29. package/coverage/src/runtime/task-rpc.ts.html +88 -82
  30. package/coverage/src/scheduler.ts.html +34 -34
  31. package/coverage/src/store.ts.html +21 -21
  32. package/coverage/src/task-backlog-coordinator.ts.html +8 -8
  33. package/coverage/src/task-reducer.ts.html +39 -39
  34. package/coverage/src/task-store.ts.html +55 -55
  35. package/coverage/src/tools/index.html +43 -43
  36. package/coverage/src/tools/loop-tools.ts.html +94 -94
  37. package/coverage/src/tools/monitor-tools.ts.html +53 -53
  38. package/coverage/src/tools/native-task-tools.ts.html +99 -78
  39. package/coverage/src/trigger-system.ts.html +8 -8
  40. package/coverage/src/ui/index.html +1 -1
  41. package/coverage/src/ui/widget.ts.html +5 -5
  42. package/dist/commands/tasks-command.js +12 -9
  43. package/dist/index.js +34 -19
  44. package/dist/monitor-manager.d.ts +4 -1
  45. package/dist/monitor-manager.js +8 -4
  46. package/dist/runtime/session-runtime.js +3 -1
  47. package/dist/runtime/task-events.d.ts +15 -0
  48. package/dist/runtime/task-events.js +13 -0
  49. package/dist/runtime/task-rpc.js +2 -0
  50. package/dist/tools/native-task-tools.js +20 -9
  51. package/docker-compose.yml +12 -0
  52. package/package.json +1 -1
  53. package/release-please-config.json +9 -0
  54. package/src/commands/tasks-command.ts +9 -9
  55. package/src/index.ts +37 -21
  56. package/src/monitor-manager.ts +14 -4
  57. package/src/runtime/session-runtime.ts +3 -1
  58. package/src/runtime/task-events.ts +41 -0
  59. package/src/runtime/task-rpc.ts +2 -0
  60. package/src/tools/native-task-tools.ts +16 -9
  61. package/vitest.config.ts +11 -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
  }
@@ -0,0 +1,12 @@
1
+ services:
2
+ ci-node20:
3
+ build:
4
+ context: .
5
+ target: node20
6
+ command: sh -c "npm run typecheck && npm run lint && npm run test:coverage && npm run build"
7
+
8
+ ci-node22:
9
+ build:
10
+ context: .
11
+ target: node22
12
+ command: sh -c "npm run typecheck && npm run lint && npm run test:coverage && npm run build"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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",
@@ -0,0 +1,9 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "release-type": "node",
5
+ "bump-minor-pre-major": true,
6
+ "bump-patch-for-minor-pre-major": true
7
+ }
8
+ }
9
+ }
@@ -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
 
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ function debug(...args: unknown[]) {
43
43
  if (DEBUG) console.error("[pi-loop]", ...args);
44
44
  }
45
45
 
46
+ function isStaleExtensionContextError(error: unknown): boolean {
47
+ return error instanceof Error && error.message.includes("extension ctx is stale");
48
+ }
49
+
46
50
  export default function (pi: ExtensionAPI) {
47
51
  const piLoopEnv = process.env.PI_LOOP;
48
52
  const piLoopScope = process.env.PI_LOOP_SCOPE as "memory" | "session" | "project" | undefined;
@@ -297,30 +301,42 @@ export default function (pi: ExtensionAPI) {
297
301
 
298
302
  // ── Native task tools (only when pi-tasks is absent) ──
299
303
 
300
- setTimeout(async () => {
304
+ const nativeTaskFallbackTimer = setTimeout(() => {
301
305
  if (tasksAvailable || nativeTasksRegistered) return;
302
- nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
303
- nativeTasksRegistered = true;
304
- const taskStore = nativeTaskStore;
305
-
306
- registerTasksCommand({
307
- pi,
308
- getNativeTaskStore: () => nativeTaskStore,
309
- evaluateTaskBacklog,
310
- updateWidget: () => {
311
- widget.update();
312
- },
313
- });
306
+ const taskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
307
+
308
+ try {
309
+ registerTasksCommand({
310
+ pi,
311
+ getNativeTaskStore: () => nativeTaskStore,
312
+ evaluateTaskBacklog,
313
+ updateWidget: () => {
314
+ widget.update();
315
+ },
316
+ });
314
317
 
315
- registerNativeTaskTools({
316
- pi,
317
- taskStore,
318
- evaluateTaskBacklog,
319
- updateWidget: () => {
320
- widget.update();
321
- },
322
- });
318
+ registerNativeTaskTools({
319
+ pi,
320
+ taskStore,
321
+ evaluateTaskBacklog,
322
+ updateWidget: () => {
323
+ widget.update();
324
+ },
325
+ });
326
+ } catch (error) {
327
+ if (isStaleExtensionContextError(error)) {
328
+ debug("native task fallback skipped: extension context went stale");
329
+ return;
330
+ }
331
+ throw error;
332
+ }
323
333
 
334
+ nativeTaskStore = taskStore;
335
+ nativeTasksRegistered = true;
324
336
  debug("native task tools registered (pi-tasks not detected)");
325
337
  }, 6000);
338
+
339
+ pi.on("session_shutdown", () => {
340
+ clearTimeout(nativeTaskFallbackTimer);
341
+ });
326
342
  }
@@ -1,5 +1,5 @@
1
1
 
2
- import { spawn } from "node:child_process";
2
+ import { type ChildProcess, spawn as nodeSpawn, type SpawnOptions } from "node:child_process";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import {
5
5
  type MonitorReducerEvent,
@@ -8,12 +8,20 @@ import {
8
8
  } from "./monitor-reducer.js";
9
9
  import type { MonitorEntry, MonitorProcess } from "./types.js";
10
10
 
11
+ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
12
+
11
13
  export class MonitorManager {
12
14
  private processes = new Map<string, MonitorProcess>();
13
15
  private nextId = 1;
14
16
  private onChange: (() => void) | undefined;
17
+ private spawnFn: SpawnFn;
15
18
 
16
- constructor(private pi: ExtensionAPI) {}
19
+ constructor(
20
+ private pi: ExtensionAPI,
21
+ spawnFn?: SpawnFn,
22
+ ) {
23
+ this.spawnFn = spawnFn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
24
+ }
17
25
 
18
26
  /**
19
27
  * Register a callback fired when a monitor's status changes or it is pruned
@@ -58,7 +66,8 @@ export class MonitorManager {
58
66
  // finish() and stop() so every terminal status is pruned consistently — a
59
67
  // stopped monitor that is never pruned lingers in list() and the widget count.
60
68
  private schedulePrune(id: string): void {
61
- setTimeout(() => {
69
+ // unref so a pending prune never keeps a one-shot (`pi -p`) process alive.
70
+ const timer = setTimeout(() => {
62
71
  this.applyReducerEvent({
63
72
  type: "MONITOR_PRUNED",
64
73
  at: Date.now(),
@@ -68,6 +77,7 @@ export class MonitorManager {
68
77
  payload: { id },
69
78
  });
70
79
  }, 30000);
80
+ timer.unref?.();
71
81
  }
72
82
 
73
83
  create(command: string, description?: string, timeout = 300000): MonitorEntry {
@@ -88,7 +98,7 @@ export class MonitorManager {
88
98
  const entry = result.state.monitorsById[id]!;
89
99
 
90
100
  const abortController = new AbortController();
91
- const child = spawn("sh", ["-c", command], {
101
+ const child = this.spawnFn("sh", ["-c", command], {
92
102
  stdio: ["ignore", "pipe", "pipe"],
93
103
  signal: abortController.signal,
94
104
  env: { ...process.env },
@@ -61,7 +61,9 @@ export function registerSessionRuntimeHooks(options: SessionRuntimeOptions): voi
61
61
  function ensureHeartbeat(): void {
62
62
  if (heartbeatTimer) return;
63
63
  heartbeatTimer = setInterval(() => {
64
- void pumpLoops();
64
+ // Swallow pump failures so a transient error never surfaces as an
65
+ // unhandled rejection; the next tick retries.
66
+ void pumpLoops().catch(() => {});
65
67
  }, HEARTBEAT_MS);
66
68
  heartbeatTimer.unref?.();
67
69
  }
@@ -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
@@ -5,20 +5,24 @@ export default defineConfig({
5
5
  environment: "node",
6
6
  include: ["test/**/*.test.ts"],
7
7
  exclude: ["node_modules", "dist"],
8
+ // CI runners are slower than local; 15s gives real-child-process
9
+ // tests (monitor stop, lifecycle) enough headroom.
10
+ testTimeout: 15000,
11
+ hookTimeout: 15000,
8
12
  coverage: {
9
13
  provider: "v8",
10
14
  reporter: ["text-summary", "html", "json"],
11
15
  include: ["src/**/*.ts"],
12
16
  // Type-only modules and test helpers carry no executable logic worth gating.
13
17
  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.
18
+ // Floors set just below current actuals (stmts 84%, branches 75%,
19
+ // funcs 95%, lines 86%) to catch regressions. Raised in Phase 4 after the
20
+ // runtime/ + tools/ suites landed.
17
21
  thresholds: {
18
- statements: 78,
19
- branches: 67,
20
- functions: 90,
21
- lines: 80,
22
+ statements: 82,
23
+ branches: 73,
24
+ functions: 94,
25
+ lines: 84,
22
26
  },
23
27
  },
24
28
  },