@trevonistrevon/pi-loop 0.5.8 → 0.6.0

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 (127) hide show
  1. package/.release-please-manifest.json +3 -0
  2. package/AGENTS.md +52 -12
  3. package/CHANGELOG.md +59 -0
  4. package/CONTRIBUTING.md +13 -3
  5. package/Dockerfile +17 -0
  6. package/README.md +47 -0
  7. package/coverage/coverage-final.json +35 -31
  8. package/coverage/index.html +64 -49
  9. package/coverage/src/api.ts.html +193 -0
  10. package/coverage/src/commands/index.html +32 -32
  11. package/coverage/src/commands/loop-command.ts.html +170 -170
  12. package/coverage/src/commands/tasks-command.ts.html +135 -135
  13. package/coverage/src/coordinator.ts.html +26 -26
  14. package/coverage/src/index.html +61 -106
  15. package/coverage/src/index.ts.html +280 -94
  16. package/coverage/src/loop-parse.ts.html +90 -57
  17. package/coverage/src/loop-reducer.ts.html +25 -25
  18. package/coverage/src/monitor-completion-coordinator.ts.html +1 -1
  19. package/coverage/src/monitor-manager.ts.html +95 -71
  20. package/coverage/src/monitor-reducer.ts.html +15 -15
  21. package/coverage/src/notification-reducer.ts.html +7 -7
  22. package/coverage/src/reducer-backed-store.ts.html +57 -57
  23. package/coverage/src/rpc/channels.ts.html +376 -0
  24. package/coverage/src/rpc/cross-extension-rpc.ts.html +541 -0
  25. package/coverage/src/rpc/index.html +131 -0
  26. package/coverage/src/runtime/index.html +89 -29
  27. package/coverage/src/{goal-coordinator.ts.html → runtime/loop-events.ts.html} +79 -73
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +5 -5
  29. package/coverage/src/runtime/native-task-rpc.ts.html +502 -0
  30. package/coverage/src/runtime/notification-runtime.ts.html +14 -14
  31. package/coverage/src/runtime/scope.ts.html +17 -17
  32. package/coverage/src/runtime/session-runtime.ts.html +40 -40
  33. package/coverage/src/runtime/task-backlog-runtime.ts.html +66 -24
  34. package/coverage/src/runtime/task-events.ts.html +208 -0
  35. package/coverage/src/runtime/task-mutations.ts.html +394 -0
  36. package/coverage/src/runtime/task-rpc.ts.html +106 -118
  37. package/coverage/src/scheduler.ts.html +12 -12
  38. package/coverage/src/store.ts.html +20 -20
  39. package/coverage/src/task-backlog-coordinator.ts.html +8 -8
  40. package/coverage/src/task-reducer.ts.html +42 -42
  41. package/coverage/src/task-store.ts.html +49 -49
  42. package/coverage/src/tools/index.html +39 -24
  43. package/coverage/src/tools/loop-tools.ts.html +12 -21
  44. package/coverage/src/tools/monitor-tools.ts.html +12 -21
  45. package/coverage/src/tools/native-task-tools.ts.html +52 -94
  46. package/coverage/src/tools/tool-result.ts.html +97 -0
  47. package/coverage/src/trigger-system.ts.html +12 -12
  48. package/coverage/src/ui/index.html +1 -1
  49. package/coverage/src/ui/widget.ts.html +23 -23
  50. package/dist/api.d.ts +11 -0
  51. package/dist/api.js +10 -0
  52. package/dist/commands/loop-command.js +2 -2
  53. package/dist/commands/tasks-command.js +3 -3
  54. package/dist/coordinator.d.ts +1 -1
  55. package/dist/index.js +80 -20
  56. package/dist/loop-parse.js +14 -5
  57. package/dist/monitor-manager.d.ts +4 -1
  58. package/dist/monitor-manager.js +5 -3
  59. package/dist/reducer-backed-store.d.ts +1 -1
  60. package/dist/reducer-backed-store.js +1 -1
  61. package/dist/rpc/channels.d.ts +73 -0
  62. package/dist/rpc/channels.js +30 -0
  63. package/dist/rpc/cross-extension-rpc.d.ts +59 -0
  64. package/dist/rpc/cross-extension-rpc.js +117 -0
  65. package/dist/runtime/loop-events.d.ts +26 -0
  66. package/dist/runtime/loop-events.js +26 -0
  67. package/dist/runtime/native-task-rpc.d.ts +22 -0
  68. package/dist/runtime/native-task-rpc.js +50 -0
  69. package/dist/runtime/task-backlog-runtime.d.ts +3 -0
  70. package/dist/runtime/task-backlog-runtime.js +4 -1
  71. package/dist/runtime/task-mutations.d.ts +39 -0
  72. package/dist/runtime/task-mutations.js +68 -0
  73. package/dist/runtime/task-rpc.d.ts +4 -0
  74. package/dist/runtime/task-rpc.js +40 -58
  75. package/dist/scheduler.js +1 -1
  76. package/dist/tools/loop-tools.js +1 -3
  77. package/dist/tools/monitor-tools.js +1 -3
  78. package/dist/tools/native-task-tools.d.ts +2 -6
  79. package/dist/tools/native-task-tools.js +25 -55
  80. package/dist/tools/tool-result.d.ts +8 -0
  81. package/dist/tools/tool-result.js +4 -0
  82. package/docker-compose.yml +12 -0
  83. package/docs/architecture/state-machine-migration.md +0 -44
  84. package/docs/architecture/state-machine-reducer-event-model.md +2 -3
  85. package/package.json +6 -1
  86. package/release-please-config.json +9 -0
  87. package/src/api.ts +36 -0
  88. package/src/commands/loop-command.ts +2 -2
  89. package/src/commands/tasks-command.ts +3 -3
  90. package/src/coordinator.ts +1 -1
  91. package/src/index.ts +84 -22
  92. package/src/loop-parse.ts +18 -7
  93. package/src/monitor-manager.ts +11 -3
  94. package/src/reducer-backed-store.ts +3 -3
  95. package/src/rpc/channels.ts +97 -0
  96. package/src/rpc/cross-extension-rpc.ts +152 -0
  97. package/src/runtime/loop-events.ts +60 -0
  98. package/src/runtime/native-task-rpc.ts +139 -0
  99. package/src/runtime/task-backlog-runtime.ts +14 -0
  100. package/src/runtime/task-mutations.ts +103 -0
  101. package/src/runtime/task-rpc.ts +48 -54
  102. package/src/scheduler.ts +1 -1
  103. package/src/tools/loop-tools.ts +1 -4
  104. package/src/tools/monitor-tools.ts +1 -4
  105. package/src/tools/native-task-tools.ts +31 -52
  106. package/src/tools/tool-result.ts +4 -0
  107. package/src/trigger-system.ts +4 -4
  108. package/vitest.config.ts +11 -7
  109. package/coverage/src/goal-reducer.ts.html +0 -982
  110. package/coverage/src/goal-store.ts.html +0 -742
  111. package/coverage/src/goal-verifier.ts.html +0 -808
  112. package/dist/goal-coordinator.d.ts +0 -22
  113. package/dist/goal-coordinator.js +0 -28
  114. package/dist/goal-reducer.d.ts +0 -98
  115. package/dist/goal-reducer.js +0 -190
  116. package/dist/goal-store.d.ts +0 -22
  117. package/dist/goal-store.js +0 -188
  118. package/dist/goal-types.d.ts +0 -82
  119. package/dist/goal-types.js +0 -1
  120. package/dist/goal-verifier.d.ts +0 -20
  121. package/dist/goal-verifier.js +0 -198
  122. package/docs/architecture/goal-state-schema.md +0 -505
  123. package/src/goal-coordinator.ts +0 -58
  124. package/src/goal-reducer.ts +0 -299
  125. package/src/goal-store.ts +0 -219
  126. package/src/goal-types.ts +0 -104
  127. package/src/goal-verifier.ts +0 -241
@@ -147,44 +147,44 @@
147
147
  <span class="cline-any cline-neutral">&nbsp;</span>
148
148
  <span class="cline-any cline-neutral">&nbsp;</span>
149
149
  <span class="cline-any cline-neutral">&nbsp;</span>
150
- <span class="cline-any cline-yes">68x</span>
151
- <span class="cline-any cline-yes">68x</span>
150
+ <span class="cline-any cline-yes">72x</span>
151
+ <span class="cline-any cline-yes">72x</span>
152
152
  <span class="cline-any cline-neutral">&nbsp;</span>
153
153
  <span class="cline-any cline-neutral">&nbsp;</span>
154
154
  <span class="cline-any cline-neutral">&nbsp;</span>
155
- <span class="cline-any cline-yes">107x</span>
155
+ <span class="cline-any cline-yes">108x</span>
156
156
  <span class="cline-any cline-neutral">&nbsp;</span>
157
157
  <span class="cline-any cline-neutral">&nbsp;</span>
158
158
  <span class="cline-any cline-neutral">&nbsp;</span>
159
- <span class="cline-any cline-yes">46x</span>
159
+ <span class="cline-any cline-yes">47x</span>
160
160
  <span class="cline-any cline-neutral">&nbsp;</span>
161
161
  <span class="cline-any cline-neutral">&nbsp;</span>
162
162
  <span class="cline-any cline-neutral">&nbsp;</span>
163
- <span class="cline-any cline-yes">63x</span>
163
+ <span class="cline-any cline-yes">67x</span>
164
164
  <span class="cline-any cline-neutral">&nbsp;</span>
165
165
  <span class="cline-any cline-neutral">&nbsp;</span>
166
166
  <span class="cline-any cline-neutral">&nbsp;</span>
167
- <span class="cline-any cline-yes">192x</span>
168
- <span class="cline-any cline-yes">164x</span>
167
+ <span class="cline-any cline-yes">191x</span>
168
+ <span class="cline-any cline-yes">168x</span>
169
169
  <span class="cline-any cline-neutral">&nbsp;</span>
170
170
  <span class="cline-any cline-neutral">&nbsp;</span>
171
171
  <span class="cline-any cline-neutral">&nbsp;</span>
172
- <span class="cline-any cline-yes">164x</span>
173
- <span class="cline-any cline-yes">164x</span>
174
- <span class="cline-any cline-yes">164x</span>
172
+ <span class="cline-any cline-yes">168x</span>
173
+ <span class="cline-any cline-yes">168x</span>
174
+ <span class="cline-any cline-yes">168x</span>
175
175
  <span class="cline-any cline-neutral">&nbsp;</span>
176
- <span class="cline-any cline-yes">164x</span>
177
- <span class="cline-any cline-yes">68x</span>
176
+ <span class="cline-any cline-yes">168x</span>
177
+ <span class="cline-any cline-yes">70x</span>
178
178
  <span class="cline-any cline-neutral">&nbsp;</span>
179
179
  <span class="cline-any cline-neutral">&nbsp;</span>
180
- <span class="cline-any cline-yes">96x</span>
181
- <span class="cline-any cline-yes">96x</span>
182
- <span class="cline-any cline-yes">96x</span>
183
- <span class="cline-any cline-yes">96x</span>
180
+ <span class="cline-any cline-yes">98x</span>
181
+ <span class="cline-any cline-yes">98x</span>
182
+ <span class="cline-any cline-yes">98x</span>
183
+ <span class="cline-any cline-yes">98x</span>
184
184
  <span class="cline-any cline-neutral">&nbsp;</span>
185
- <span class="cline-any cline-yes">96x</span>
186
- <span class="cline-any cline-yes">96x</span>
187
- <span class="cline-any cline-yes">96x</span>
185
+ <span class="cline-any cline-yes">98x</span>
186
+ <span class="cline-any cline-yes">98x</span>
187
+ <span class="cline-any cline-yes">98x</span>
188
188
  <span class="cline-any cline-neutral">&nbsp;</span>
189
189
  <span class="cline-any cline-neutral">&nbsp;</span>
190
190
  <span class="cline-any cline-neutral">&nbsp;</span>
@@ -193,12 +193,12 @@
193
193
  <span class="cline-any cline-neutral">&nbsp;</span>
194
194
  <span class="cline-any cline-neutral">&nbsp;</span>
195
195
  <span class="cline-any cline-neutral">&nbsp;</span>
196
- <span class="cline-any cline-yes">120x</span>
196
+ <span class="cline-any cline-yes">117x</span>
197
197
  <span class="cline-any cline-neutral">&nbsp;</span>
198
198
  <span class="cline-any cline-neutral">&nbsp;</span>
199
199
  <span class="cline-any cline-neutral">&nbsp;</span>
200
- <span class="cline-any cline-yes">45x</span>
201
- <span class="cline-any cline-yes">44x</span>
200
+ <span class="cline-any cline-yes">40x</span>
201
+ <span class="cline-any cline-yes">39x</span>
202
202
  <span class="cline-any cline-yes">6x</span>
203
203
  <span class="cline-any cline-neutral">&nbsp;</span>
204
204
  <span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
@@ -277,7 +277,7 @@ function isStatusVisibleLoop(loop: LoopEntry): boolean {
277
277
  <div class='footer quiet pad2 space-top1 center small'>
278
278
  Code coverage generated by
279
279
  <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
280
- at 2026-06-19T00:56:38.236Z
280
+ at 2026-07-02T19:31:50.067Z
281
281
  </div>
282
282
  <script src="../../prettify.js"></script>
283
283
  <script>
package/dist/api.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public API surface for sibling extensions (imported as
3
+ * `@trevonistrevon/pi-loop/api`). Everything else under src/ is internal —
4
+ * the package `exports` map deliberately blocks deep imports.
5
+ */
6
+ export { type CleanReply, type CreateTaskParams, type CreateTaskReply, type PendingReply, type PingReply, replyChannel, type SpawnParams, type SpawnReply, SUBAGENTS_RPC, TASK_EVENTS, TASKS_RPC, type TaskEntryWire, type TaskStatusWire, type UpdateTaskParams, type UpdateTaskReply, } from "./rpc/channels.js";
7
+ export { type HandleRpcOptions, handleRpc, PROTOCOL_VERSION, RpcError, type RpcEventBus, type RpcReply, rpcCall, rpcProbe, } from "./rpc/cross-extension-rpc.js";
8
+ export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
9
+ export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
10
+ export { TaskStore } from "./task-store.js";
11
+ export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
package/dist/api.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Public API surface for sibling extensions (imported as
3
+ * `@trevonistrevon/pi-loop/api`). Everything else under src/ is internal —
4
+ * the package `exports` map deliberately blocks deep imports.
5
+ */
6
+ export { replyChannel, SUBAGENTS_RPC, TASK_EVENTS, TASKS_RPC, } from "./rpc/channels.js";
7
+ export { handleRpc, PROTOCOL_VERSION, RpcError, rpcCall, rpcProbe, } from "./rpc/cross-extension-rpc.js";
8
+ export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
9
+ export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
10
+ export { TaskStore } from "./task-store.js";
@@ -53,7 +53,7 @@ export function registerLoopCommand(options) {
53
53
  if (!selected || selected === "< Back")
54
54
  return;
55
55
  const match = selected.match(/#(\d+)/);
56
- if (match) {
56
+ if (match?.[1]) {
57
57
  const entry = getStore().get(match[1]);
58
58
  if (entry) {
59
59
  const actions = ["x Delete"];
@@ -114,7 +114,7 @@ export function registerLoopCommand(options) {
114
114
  }
115
115
  const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
116
116
  if (intervalMatch) {
117
- const interval = intervalMatch[1];
117
+ const interval = intervalMatch[1] ?? intervalMatch[0];
118
118
  const prompt = trimmed.slice(intervalMatch[0].length).trim();
119
119
  if (!prompt) {
120
120
  ui.notify("Provide a prompt after the interval, e.g., /loop 5m check the deploy", "warning");
@@ -47,10 +47,10 @@ export function registerTasksCommand(options) {
47
47
  await createNativeTaskInteractively(ui);
48
48
  return viewNativeTasks(ui);
49
49
  }
50
- const match = selected.match(/#(\d+)/);
51
- if (!match)
50
+ const taskId = selected.match(/#(\d+)/)?.[1];
51
+ if (!taskId)
52
52
  return viewNativeTasks(ui);
53
- const task = taskStore.get(match[1]);
53
+ const task = taskStore.get(taskId);
54
54
  if (!task)
55
55
  return viewNativeTasks(ui);
56
56
  const actions = ["x Delete"];
@@ -1,5 +1,5 @@
1
1
  export type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
2
- export type ReducerEntityType = "task" | "loop" | "monitor" | "notification" | "goal";
2
+ export type ReducerEntityType = "task" | "loop" | "monitor" | "notification";
3
3
  export interface ReducerEvent<TType extends string = string, TPayload = unknown> {
4
4
  type: TType;
5
5
  at: number;
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { registerTasksCommand } from "./commands/tasks-command.js";
18
18
  import { atMaxFires } from "./loop-reducer.js";
19
19
  import { MonitorManager } from "./monitor-manager.js";
20
20
  import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
21
+ import { registerNativeTaskRpc } from "./runtime/native-task-rpc.js";
21
22
  import { createNotificationRuntime, } from "./runtime/notification-runtime.js";
22
23
  import { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
23
24
  import { registerSessionRuntimeHooks } from "./runtime/session-runtime.js";
@@ -36,6 +37,9 @@ function debug(...args) {
36
37
  if (DEBUG)
37
38
  console.error("[pi-loop]", ...args);
38
39
  }
40
+ function isStaleExtensionContextError(error) {
41
+ return error instanceof Error && error.message.includes("extension ctx is stale");
42
+ }
39
43
  export default function (pi) {
40
44
  const piLoopEnv = process.env.PI_LOOP;
41
45
  const piLoopScope = process.env.PI_LOOP_SCOPE;
@@ -50,7 +54,9 @@ export default function (pi) {
50
54
  // call), so stale monitors don't linger in the count between turns.
51
55
  monitorManager.setOnChange(() => widget.update());
52
56
  widget.setTaskSummaryProvider(() => {
53
- if (!nativeTaskStore)
57
+ // Once pi-tasks owns the channels, any native store created during the
58
+ // detection window is a shadow — never surface it in the status line.
59
+ if (tasksAvailable || !nativeTaskStore)
54
60
  return { count: 0 };
55
61
  const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
56
62
  const active = tasks.find(t => t.status === "in_progress");
@@ -66,8 +72,18 @@ export default function (pi) {
66
72
  triggerSystem = new TriggerSystem(pi, scheduler, store, onLoopFire);
67
73
  // ── pi-tasks integration ──
68
74
  let tasksAvailable = false;
75
+ let tasksDetectionSettled = false;
69
76
  let nativeTaskStore;
70
77
  let nativeTasksRegistered = false;
78
+ function getOrCreateNativeTaskStore() {
79
+ // pi-tasks owns the task channels; don't create a shadow store.
80
+ if (tasksAvailable)
81
+ return undefined;
82
+ if (!nativeTaskStore) {
83
+ nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
84
+ }
85
+ return nativeTaskStore;
86
+ }
71
87
  const taskRuntime = createTaskRuntimeBridge({
72
88
  pi,
73
89
  isTasksAvailable: () => tasksAvailable,
@@ -83,6 +99,29 @@ export default function (pi) {
83
99
  widget.update();
84
100
  await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
85
101
  },
102
+ onDetectionStarted: () => {
103
+ tasksDetectionSettled = false;
104
+ },
105
+ onDetectionSettled: () => {
106
+ tasksDetectionSettled = true;
107
+ },
108
+ debug,
109
+ });
110
+ // The RPC server registers at init (not behind the 6s tool-fallback timer) so
111
+ // early cross-extension calls never race the timer; it stands down via
112
+ // isEnabled once an external pi-tasks is detected. checkTasksVersion ignores
113
+ // this server's own ping reply via its provider field. Mutating verbs stay
114
+ // silent until the detection probe settles, so a co-resident pi-tasks can
115
+ // never race the native server into creating divergent task state.
116
+ registerNativeTaskRpc({
117
+ pi,
118
+ getNativeTaskStore: getOrCreateNativeTaskStore,
119
+ isEnabled: () => !tasksAvailable,
120
+ isDetectionSettled: () => tasksDetectionSettled,
121
+ evaluateTaskBacklog: (taskStore, pendingCount) => evaluateTaskBacklog(taskStore, pendingCount),
122
+ updateWidget: () => {
123
+ widget.update();
124
+ },
86
125
  debug,
87
126
  });
88
127
  taskRuntime.checkTasksVersion();
@@ -153,6 +192,12 @@ export default function (pi) {
153
192
  hasPendingTasks: () => hasPendingTasks(),
154
193
  bootstrapTaskLoop: (entry) => maybeBootstrapTaskLoop(entry),
155
194
  triggerHasEventSource,
195
+ emitLoopAutodeleted: (payload) => {
196
+ pi.events.emit("loops:autodeleted", payload);
197
+ },
198
+ emitTaskBacklogEmpty: (payload) => {
199
+ pi.events.emit("tasks:backlog_empty", payload);
200
+ },
156
201
  debug,
157
202
  });
158
203
  const flushPendingNotifications = notificationRuntime.flushPendingNotifications;
@@ -263,28 +308,43 @@ export default function (pi) {
263
308
  },
264
309
  });
265
310
  // ── Native task tools (only when pi-tasks is absent) ──
266
- setTimeout(async () => {
311
+ // Only tool/command registration stays deferred: the tool names collide with
312
+ // pi-tasks and cannot be unregistered. The RPC server is already live (above).
313
+ const nativeTaskFallbackTimer = setTimeout(() => {
267
314
  if (tasksAvailable || nativeTasksRegistered)
268
315
  return;
269
- nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
316
+ const taskStore = getOrCreateNativeTaskStore();
317
+ if (!taskStore)
318
+ return;
319
+ try {
320
+ registerTasksCommand({
321
+ pi,
322
+ getNativeTaskStore: () => nativeTaskStore,
323
+ evaluateTaskBacklog,
324
+ updateWidget: () => {
325
+ widget.update();
326
+ },
327
+ });
328
+ registerNativeTaskTools({
329
+ pi,
330
+ taskStore,
331
+ evaluateTaskBacklog,
332
+ updateWidget: () => {
333
+ widget.update();
334
+ },
335
+ });
336
+ }
337
+ catch (error) {
338
+ if (isStaleExtensionContextError(error)) {
339
+ debug("native task fallback skipped: extension context went stale");
340
+ return;
341
+ }
342
+ throw error;
343
+ }
270
344
  nativeTasksRegistered = true;
271
- const taskStore = nativeTaskStore;
272
- registerTasksCommand({
273
- pi,
274
- getNativeTaskStore: () => nativeTaskStore,
275
- evaluateTaskBacklog,
276
- updateWidget: () => {
277
- widget.update();
278
- },
279
- });
280
- registerNativeTaskTools({
281
- pi,
282
- taskStore,
283
- evaluateTaskBacklog,
284
- updateWidget: () => {
285
- widget.update();
286
- },
287
- });
288
345
  debug("native task tools registered (pi-tasks not detected)");
289
346
  }, 6000);
347
+ pi.on("session_shutdown", () => {
348
+ clearTimeout(nativeTaskFallbackTimer);
349
+ });
290
350
  }
@@ -21,6 +21,8 @@ const COMMON_INTERVALS = {
21
21
  86400: "0 0 * * *",
22
22
  };
23
23
  function roundToNearestCommon(seconds) {
24
+ // COMMON_INTERVALS is a non-empty const table, so keys[0] and
25
+ // COMMON_INTERVALS[best] below are true invariants, not runtime fallbacks.
24
26
  const keys = Object.keys(COMMON_INTERVALS).map(Number).sort((a, b) => a - b);
25
27
  let best = keys[0];
26
28
  for (const k of keys) {
@@ -55,8 +57,8 @@ export function parseInterval(input) {
55
57
  }
56
58
  const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
57
59
  if (match) {
58
- const value = parseInt(match[1], 10);
59
- const unit = match[2].toLowerCase();
60
+ const value = parseInt(match[1] ?? "", 10);
61
+ const unit = (match[2] ?? "").toLowerCase();
60
62
  const totalSec = value * (UNIT_TO_CRON[unit] ?? 60);
61
63
  if (totalSec < 60) {
62
64
  return { cron: `*/1 * * * *`, description: `${totalSec} seconds (rounded to 1 minute)` };
@@ -70,6 +72,13 @@ export function cronToNextFire(cronExpr, fromDate = new Date()) {
70
72
  if (parts.length !== 5)
71
73
  throw new Error(`Invalid cron expression: ${cronExpr}`);
72
74
  const [minF, hourF, dayF, monthF, dowF] = parts;
75
+ if (minF === undefined ||
76
+ hourF === undefined ||
77
+ dayF === undefined ||
78
+ monthF === undefined ||
79
+ dowF === undefined) {
80
+ throw new Error(`Invalid cron expression: ${cronExpr}`);
81
+ }
73
82
  const now = new Date(fromDate);
74
83
  now.setSeconds(0, 0);
75
84
  for (let minutesAdvanced = 1; minutesAdvanced < 525600; minutesAdvanced++) {
@@ -96,7 +105,7 @@ function cronFieldMatches(field, value) {
96
105
  if (part === "*")
97
106
  return true;
98
107
  if (part.includes("/")) {
99
- const [range, stepStr] = part.split("/");
108
+ const [range = "", stepStr = ""] = part.split("/");
100
109
  const step = parseInt(stepStr, 10);
101
110
  let rangeMin;
102
111
  let rangeMax;
@@ -105,7 +114,7 @@ function cronFieldMatches(field, value) {
105
114
  rangeMax = 59;
106
115
  }
107
116
  else if (range.includes("-")) {
108
- const [minS, maxS] = range.split("-");
117
+ const [minS = "", maxS = ""] = range.split("-");
109
118
  rangeMin = parseInt(minS, 10);
110
119
  rangeMax = parseInt(maxS, 10);
111
120
  }
@@ -121,7 +130,7 @@ function cronFieldMatches(field, value) {
121
130
  continue;
122
131
  }
123
132
  if (part.includes("-")) {
124
- const [minS, maxS] = part.split("-");
133
+ const [minS = "", maxS = ""] = part.split("-");
125
134
  const min = parseInt(minS, 10);
126
135
  const max = parseInt(maxS, 10);
127
136
  if (value >= min && value <= max)
@@ -1,11 +1,14 @@
1
+ import { type ChildProcess, type SpawnOptions } from "node:child_process";
1
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import type { MonitorEntry, MonitorProcess } from "./types.js";
4
+ export type SpawnFn = (command: string, args: string[], options: SpawnOptions) => ChildProcess;
3
5
  export declare class MonitorManager {
4
6
  private pi;
5
7
  private processes;
6
8
  private nextId;
7
9
  private onChange;
8
- constructor(pi: ExtensionAPI);
10
+ private spawnFn;
11
+ constructor(pi: ExtensionAPI, spawnFn?: SpawnFn);
9
12
  /**
10
13
  * Register a callback fired when a monitor's status changes or it is pruned
11
14
  * (e.g. autonomous completion/error/stop/prune with no tool call). Used to
@@ -1,12 +1,14 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { reduceMonitorState, } from "./monitor-reducer.js";
3
3
  export class MonitorManager {
4
4
  pi;
5
5
  processes = new Map();
6
6
  nextId = 1;
7
7
  onChange;
8
- constructor(pi) {
8
+ spawnFn;
9
+ constructor(pi, spawnFn) {
9
10
  this.pi = pi;
11
+ this.spawnFn = spawnFn ?? ((cmd, args, opts) => nodeSpawn(cmd, args, opts));
10
12
  }
11
13
  /**
12
14
  * Register a callback fired when a monitor's status changes or it is pruned
@@ -76,7 +78,7 @@ export class MonitorManager {
76
78
  const id = String(this.nextId - 1);
77
79
  const entry = result.state.monitorsById[id];
78
80
  const abortController = new AbortController();
79
- const child = spawn("sh", ["-c", command], {
81
+ const child = this.spawnFn("sh", ["-c", command], {
80
82
  stdio: ["ignore", "pipe", "pipe"],
81
83
  signal: abortController.signal,
82
84
  env: { ...process.env },
@@ -25,7 +25,7 @@ export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
25
25
  }
26
26
  /**
27
27
  * Shared persistence + reducer-dispatch machinery for the file-backed entity
28
- * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
28
+ * stores (loops, tasks). Owns file locking, signature-gated load, atomic
29
29
  * save, and reducer application; subclasses add only their entity-specific
30
30
  * command methods.
31
31
  *
@@ -48,7 +48,7 @@ function isProcessRunning(pid) {
48
48
  }
49
49
  /**
50
50
  * Shared persistence + reducer-dispatch machinery for the file-backed entity
51
- * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
51
+ * stores (loops, tasks). Owns file locking, signature-gated load, atomic
52
52
  * save, and reducer application; subclasses add only their entity-specific
53
53
  * command methods.
54
54
  *
@@ -0,0 +1,73 @@
1
+ /** Cross-extension RPC channels served by a tasks provider (pi-tasks or pi-loop native). */
2
+ export declare const TASKS_RPC: {
3
+ readonly ping: "tasks:rpc:ping";
4
+ readonly create: "tasks:rpc:create";
5
+ readonly update: "tasks:rpc:update";
6
+ readonly pending: "tasks:rpc:pending";
7
+ readonly clean: "tasks:rpc:clean";
8
+ };
9
+ /** Cross-extension RPC channels served by @tintinweb/pi-subagents. */
10
+ export declare const SUBAGENTS_RPC: {
11
+ readonly ping: "subagents:rpc:ping";
12
+ readonly spawn: "subagents:rpc:spawn";
13
+ readonly stop: "subagents:rpc:stop";
14
+ };
15
+ /** Broadcast (fire-and-forget) task lifecycle events. */
16
+ export declare const TASK_EVENTS: {
17
+ readonly ready: "tasks:ready";
18
+ readonly created: "tasks:created";
19
+ readonly started: "tasks:started";
20
+ readonly completed: "tasks:completed";
21
+ readonly reopened: "tasks:reopened";
22
+ readonly updated: "tasks:updated";
23
+ readonly deleted: "tasks:deleted";
24
+ };
25
+ export declare function replyChannel(channel: string, requestId: string): string;
26
+ export type TaskStatusWire = "pending" | "in_progress" | "completed";
27
+ export interface TaskEntryWire {
28
+ id: string;
29
+ subject: string;
30
+ description: string;
31
+ status: TaskStatusWire;
32
+ createdAt: number;
33
+ updatedAt: number;
34
+ completedAt?: number;
35
+ metadata?: Record<string, unknown>;
36
+ }
37
+ export interface CreateTaskParams {
38
+ subject: string;
39
+ description: string;
40
+ metadata?: Record<string, unknown>;
41
+ }
42
+ export interface CreateTaskReply {
43
+ id: string;
44
+ task: TaskEntryWire;
45
+ }
46
+ export interface UpdateTaskParams {
47
+ id: string;
48
+ status?: TaskStatusWire;
49
+ subject?: string;
50
+ description?: string;
51
+ }
52
+ export interface UpdateTaskReply {
53
+ task: TaskEntryWire;
54
+ }
55
+ export interface PendingReply {
56
+ pending: number;
57
+ }
58
+ export interface CleanReply {
59
+ pruned: number;
60
+ }
61
+ export interface PingReply {
62
+ version: number;
63
+ /** Identifies which extension answered; lets a provider ignore its own reply. */
64
+ provider?: string;
65
+ }
66
+ export interface SpawnParams {
67
+ type: string;
68
+ prompt: string;
69
+ options?: Record<string, unknown>;
70
+ }
71
+ export interface SpawnReply {
72
+ id: string;
73
+ }
@@ -0,0 +1,30 @@
1
+ // VENDORED MODULE — canonical copy shared verbatim by pi-loop and pi-orca.
2
+ // If you edit this file, copy it to the sibling repo and bump VENDOR_REV.
3
+ // VENDOR_REV: 1
4
+ /** Cross-extension RPC channels served by a tasks provider (pi-tasks or pi-loop native). */
5
+ export const TASKS_RPC = {
6
+ ping: "tasks:rpc:ping",
7
+ create: "tasks:rpc:create",
8
+ update: "tasks:rpc:update",
9
+ pending: "tasks:rpc:pending",
10
+ clean: "tasks:rpc:clean",
11
+ };
12
+ /** Cross-extension RPC channels served by @tintinweb/pi-subagents. */
13
+ export const SUBAGENTS_RPC = {
14
+ ping: "subagents:rpc:ping",
15
+ spawn: "subagents:rpc:spawn",
16
+ stop: "subagents:rpc:stop",
17
+ };
18
+ /** Broadcast (fire-and-forget) task lifecycle events. */
19
+ export const TASK_EVENTS = {
20
+ ready: "tasks:ready",
21
+ created: "tasks:created",
22
+ started: "tasks:started",
23
+ completed: "tasks:completed",
24
+ reopened: "tasks:reopened",
25
+ updated: "tasks:updated",
26
+ deleted: "tasks:deleted",
27
+ };
28
+ export function replyChannel(channel, requestId) {
29
+ return `${channel}:reply:${requestId}`;
30
+ }
@@ -0,0 +1,59 @@
1
+ import { type PingReply } from "./channels.js";
2
+ /**
3
+ * Cross-extension request/reply over the in-process pi event bus.
4
+ *
5
+ * Wire contract (matches the pi-mono convention used by @tintinweb/pi-subagents):
6
+ * request: emit(channel, { requestId, ...params })
7
+ * reply: emit(`${channel}:reply:${requestId}`, RpcReply)
8
+ */
9
+ export declare const PROTOCOL_VERSION = 2;
10
+ export type RpcReply<T = void> = {
11
+ success: true;
12
+ data?: T;
13
+ } | {
14
+ success: false;
15
+ error: string;
16
+ };
17
+ export declare class RpcError extends Error {
18
+ readonly channel: string;
19
+ readonly timedOut: boolean;
20
+ constructor(channel: string, message: string, timedOut?: boolean);
21
+ }
22
+ /** Minimal event-bus surface — satisfied by pi.events. */
23
+ export interface RpcEventBus {
24
+ on(event: string, handler: (data: unknown) => void): () => void;
25
+ emit(event: string, data: unknown): void;
26
+ }
27
+ /**
28
+ * Call a remote handler and await its reply.
29
+ *
30
+ * Rejects with RpcError on a failure envelope or on timeout (timedOut=true).
31
+ * Callers that treat failure as "fall back" wrap this in try/catch at the
32
+ * layer that knows the fallback — the RPC layer itself never returns sentinels.
33
+ */
34
+ export declare function rpcCall<T = void>(bus: RpcEventBus, channel: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<T>;
35
+ /**
36
+ * Detection probe: resolves undefined when nobody answers the ping channel.
37
+ * Absence of a provider is expected, not an error.
38
+ *
39
+ * First reply wins. If the probing extension also serves this channel, its
40
+ * own reply will settle the probe — in that case listen on the reply channel
41
+ * directly for the full window and filter by PingReply.provider instead.
42
+ */
43
+ export declare function rpcProbe(bus: RpcEventBus, pingChannel: string, timeoutMs?: number): Promise<PingReply | undefined>;
44
+ export interface HandleRpcOptions {
45
+ /**
46
+ * When false, the handler is a silent no-op: another extension owns the
47
+ * channel and will reply; emitting a failure here would race its reply.
48
+ */
49
+ enabled?: () => boolean;
50
+ debug?: (...args: unknown[]) => void;
51
+ }
52
+ /**
53
+ * Register a server handler for one RPC channel. Returns the unsubscribe fn.
54
+ *
55
+ * Malformed requests get a failure reply, never a silent drop — the only
56
+ * silent case is a missing requestId, which leaves no reply address.
57
+ * A thrown/rejected fn becomes a failure envelope.
58
+ */
59
+ export declare function handleRpc<P extends object, R = unknown>(bus: RpcEventBus, channel: string, fn: (params: P) => R | Promise<R>, opts?: HandleRpcOptions): () => void;