@trevonistrevon/pi-loop 0.5.9 → 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 (121) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/AGENTS.md +52 -12
  3. package/CHANGELOG.md +59 -0
  4. package/CONTRIBUTING.md +13 -3
  5. package/README.md +47 -0
  6. package/coverage/coverage-final.json +35 -32
  7. package/coverage/index.html +64 -49
  8. package/coverage/src/api.ts.html +193 -0
  9. package/coverage/src/commands/index.html +32 -32
  10. package/coverage/src/commands/loop-command.ts.html +170 -170
  11. package/coverage/src/commands/tasks-command.ts.html +129 -129
  12. package/coverage/src/coordinator.ts.html +26 -26
  13. package/coverage/src/index.html +61 -106
  14. package/coverage/src/index.ts.html +224 -86
  15. package/coverage/src/loop-parse.ts.html +106 -73
  16. package/coverage/src/loop-reducer.ts.html +21 -21
  17. package/coverage/src/monitor-completion-coordinator.ts.html +1 -1
  18. package/coverage/src/monitor-manager.ts.html +95 -71
  19. package/coverage/src/monitor-reducer.ts.html +15 -15
  20. package/coverage/src/notification-reducer.ts.html +6 -6
  21. package/coverage/src/reducer-backed-store.ts.html +57 -57
  22. package/coverage/src/rpc/channels.ts.html +376 -0
  23. package/coverage/src/rpc/cross-extension-rpc.ts.html +541 -0
  24. package/coverage/src/rpc/index.html +131 -0
  25. package/coverage/src/runtime/index.html +68 -23
  26. package/coverage/src/{goal-coordinator.ts.html → runtime/loop-events.ts.html} +79 -73
  27. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +5 -5
  28. package/coverage/src/runtime/native-task-rpc.ts.html +502 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +22 -22
  30. package/coverage/src/runtime/scope.ts.html +14 -14
  31. package/coverage/src/runtime/session-runtime.ts.html +32 -32
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +65 -23
  33. package/coverage/src/runtime/task-events.ts.html +2 -2
  34. package/coverage/src/runtime/task-mutations.ts.html +394 -0
  35. package/coverage/src/runtime/task-rpc.ts.html +102 -120
  36. package/coverage/src/scheduler.ts.html +29 -29
  37. package/coverage/src/store.ts.html +27 -27
  38. package/coverage/src/task-backlog-coordinator.ts.html +8 -8
  39. package/coverage/src/task-reducer.ts.html +42 -42
  40. package/coverage/src/task-store.ts.html +49 -49
  41. package/coverage/src/tools/index.html +39 -24
  42. package/coverage/src/tools/loop-tools.ts.html +12 -21
  43. package/coverage/src/tools/monitor-tools.ts.html +12 -21
  44. package/coverage/src/tools/native-task-tools.ts.html +57 -120
  45. package/coverage/src/tools/tool-result.ts.html +97 -0
  46. package/coverage/src/trigger-system.ts.html +12 -12
  47. package/coverage/src/ui/index.html +1 -1
  48. package/coverage/src/ui/widget.ts.html +23 -23
  49. package/dist/api.d.ts +11 -0
  50. package/dist/api.js +10 -0
  51. package/dist/commands/loop-command.js +2 -2
  52. package/dist/commands/tasks-command.js +3 -3
  53. package/dist/coordinator.d.ts +1 -1
  54. package/dist/index.js +48 -3
  55. package/dist/loop-parse.js +14 -5
  56. package/dist/reducer-backed-store.d.ts +1 -1
  57. package/dist/reducer-backed-store.js +1 -1
  58. package/dist/rpc/channels.d.ts +73 -0
  59. package/dist/rpc/channels.js +30 -0
  60. package/dist/rpc/cross-extension-rpc.d.ts +59 -0
  61. package/dist/rpc/cross-extension-rpc.js +117 -0
  62. package/dist/runtime/loop-events.d.ts +26 -0
  63. package/dist/runtime/loop-events.js +26 -0
  64. package/dist/runtime/native-task-rpc.d.ts +22 -0
  65. package/dist/runtime/native-task-rpc.js +50 -0
  66. package/dist/runtime/task-backlog-runtime.d.ts +3 -0
  67. package/dist/runtime/task-backlog-runtime.js +4 -1
  68. package/dist/runtime/task-mutations.d.ts +39 -0
  69. package/dist/runtime/task-mutations.js +68 -0
  70. package/dist/runtime/task-rpc.d.ts +4 -0
  71. package/dist/runtime/task-rpc.js +40 -58
  72. package/dist/scheduler.js +1 -1
  73. package/dist/tools/loop-tools.js +1 -3
  74. package/dist/tools/monitor-tools.js +1 -3
  75. package/dist/tools/native-task-tools.d.ts +2 -6
  76. package/dist/tools/native-task-tools.js +25 -55
  77. package/dist/tools/tool-result.d.ts +8 -0
  78. package/dist/tools/tool-result.js +4 -0
  79. package/docs/architecture/state-machine-migration.md +0 -44
  80. package/docs/architecture/state-machine-reducer-event-model.md +2 -3
  81. package/package.json +6 -1
  82. package/src/api.ts +36 -0
  83. package/src/commands/loop-command.ts +2 -2
  84. package/src/commands/tasks-command.ts +3 -3
  85. package/src/coordinator.ts +1 -1
  86. package/src/index.ts +49 -3
  87. package/src/loop-parse.ts +18 -7
  88. package/src/reducer-backed-store.ts +3 -3
  89. package/src/rpc/channels.ts +97 -0
  90. package/src/rpc/cross-extension-rpc.ts +152 -0
  91. package/src/runtime/loop-events.ts +60 -0
  92. package/src/runtime/native-task-rpc.ts +139 -0
  93. package/src/runtime/task-backlog-runtime.ts +14 -0
  94. package/src/runtime/task-mutations.ts +103 -0
  95. package/src/runtime/task-rpc.ts +48 -54
  96. package/src/scheduler.ts +1 -1
  97. package/src/tools/loop-tools.ts +1 -4
  98. package/src/tools/monitor-tools.ts +1 -4
  99. package/src/tools/native-task-tools.ts +31 -52
  100. package/src/tools/tool-result.ts +4 -0
  101. package/src/trigger-system.ts +4 -4
  102. package/vitest.config.ts +7 -7
  103. package/coverage/src/goal-reducer.ts.html +0 -982
  104. package/coverage/src/goal-store.ts.html +0 -742
  105. package/coverage/src/goal-verifier.ts.html +0 -808
  106. package/dist/goal-coordinator.d.ts +0 -22
  107. package/dist/goal-coordinator.js +0 -28
  108. package/dist/goal-reducer.d.ts +0 -98
  109. package/dist/goal-reducer.js +0 -190
  110. package/dist/goal-store.d.ts +0 -22
  111. package/dist/goal-store.js +0 -188
  112. package/dist/goal-types.d.ts +0 -82
  113. package/dist/goal-types.js +0 -1
  114. package/dist/goal-verifier.d.ts +0 -20
  115. package/dist/goal-verifier.js +0 -198
  116. package/docs/architecture/goal-state-schema.md +0 -505
  117. package/src/goal-coordinator.ts +0 -58
  118. package/src/goal-reducer.ts +0 -299
  119. package/src/goal-store.ts +0 -219
  120. package/src/goal-types.ts +0 -104
  121. package/src/goal-verifier.ts +0 -241
@@ -1,10 +1,14 @@
1
1
  import { Type } from "typebox";
2
- import { emitNativeTaskEvent } from "../runtime/task-events.js";
3
- function textResult(msg) {
4
- return { content: [{ type: "text", text: msg }], details: undefined };
2
+ import { createTask, deleteTask, updateTask, } from "../runtime/task-mutations.js";
3
+ import { textResult } from "./tool-result.js";
4
+ function backlogSuffix(backlog) {
5
+ return backlog.created && backlog.entry
6
+ ? `\nBacklog worker loop #${backlog.entry.id} created`
7
+ : "";
5
8
  }
6
9
  export function registerNativeTaskTools(options) {
7
10
  const { pi, taskStore, evaluateTaskBacklog, updateWidget } = options;
11
+ const mutationCtx = { pi, taskStore, evaluateTaskBacklog, updateWidget };
8
12
  pi.registerTool({
9
13
  name: "TaskCreate",
10
14
  label: "TaskCreate",
@@ -30,14 +34,11 @@ Fields:
30
34
  description: Type.String({ description: "Detailed description of what needs to be done" }),
31
35
  }),
32
36
  async execute(_toolCallId, params) {
33
- const entry = taskStore.create(params.subject, params.description);
34
- emitNativeTaskEvent(pi, "tasks:created", entry);
35
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
36
- updateWidget();
37
- const autoLoopMsg = backlog.created && backlog.entry
38
- ? `\nBacklog worker loop #${backlog.entry.id} created`
39
- : "";
40
- return Promise.resolve(textResult(`Task #${entry.id} created: ${entry.subject}${autoLoopMsg}`));
37
+ const { entry, backlog } = await createTask(mutationCtx, {
38
+ subject: params.subject,
39
+ description: params.description,
40
+ });
41
+ return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
41
42
  },
42
43
  });
43
44
  pi.registerTool({
@@ -84,41 +85,16 @@ Parameters: id (required), status, subject, description`,
84
85
  }),
85
86
  async execute(_toolCallId, params) {
86
87
  const { id, status, subject, description } = params;
87
- let entry = taskStore.get(id);
88
- if (!entry)
89
- return Promise.resolve(textResult(`Task #${id} not found`));
90
- const previousStatus = entry.status;
91
- if (status === "in_progress") {
92
- entry = taskStore.start(id);
93
- if (entry)
94
- emitNativeTaskEvent(pi, "tasks:started", entry, previousStatus);
95
- }
96
- else if (status === "completed") {
97
- entry = taskStore.complete(id);
98
- if (entry)
99
- emitNativeTaskEvent(pi, "tasks:completed", entry, previousStatus);
100
- }
101
- else if (status === "pending") {
102
- entry = taskStore.reopen(id);
103
- if (entry)
104
- emitNativeTaskEvent(pi, "tasks:reopened", entry, previousStatus);
105
- }
106
- if (!entry)
107
- return Promise.resolve(textResult(`Task #${id} not found`));
108
- if (subject !== undefined || description !== undefined) {
109
- entry = taskStore.updateDetails(id, { subject, description });
110
- if (entry)
111
- emitNativeTaskEvent(pi, "tasks:updated", entry, previousStatus);
112
- }
113
- if (!entry)
114
- return Promise.resolve(textResult(`Task #${id} not found`));
115
- updateWidget();
116
- const backlog = await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
88
+ const result = await updateTask(mutationCtx, {
89
+ id,
90
+ status: status,
91
+ subject,
92
+ description,
93
+ });
94
+ if (!result)
95
+ return textResult(`Task #${id} not found`);
117
96
  const statusMsg = status ? ` → ${status}` : "";
118
- const autoLoopMsg = backlog.created && backlog.entry
119
- ? `\nBacklog worker loop #${backlog.entry.id} created`
120
- : "";
121
- return Promise.resolve(textResult(`Task #${id} updated${statusMsg}${autoLoopMsg}`));
97
+ return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
122
98
  },
123
99
  });
124
100
  pi.registerTool({
@@ -129,16 +105,10 @@ Parameters: id (required), status, subject, description`,
129
105
  id: Type.String({ description: "Task ID to delete" }),
130
106
  }),
131
107
  async execute(_toolCallId, params) {
132
- const existing = taskStore.get(params.id);
133
- const deleted = taskStore.delete(params.id);
134
- updateWidget();
135
- if (deleted) {
136
- if (existing)
137
- emitNativeTaskEvent(pi, "tasks:deleted", existing, existing.status);
138
- await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
139
- return Promise.resolve(textResult(`Task #${params.id} deleted`));
140
- }
141
- return Promise.resolve(textResult(`Task #${params.id} not found`));
108
+ const result = await deleteTask(mutationCtx, params.id);
109
+ if (!result)
110
+ return textResult(`Task #${params.id} not found`);
111
+ return textResult(`Task #${params.id} deleted`);
142
112
  },
143
113
  });
144
114
  }
@@ -0,0 +1,8 @@
1
+ /** Plain-text tool result in the shape pi's registerTool expects. */
2
+ export declare function textResult(msg: string): {
3
+ content: {
4
+ type: "text";
5
+ text: string;
6
+ }[];
7
+ details: undefined;
8
+ };
@@ -0,0 +1,4 @@
1
+ /** Plain-text tool result in the shape pi's registerTool expects. */
2
+ export function textResult(msg) {
3
+ return { content: [{ type: "text", text: msg }], details: undefined };
4
+ }
@@ -9,7 +9,6 @@ It is the capstone document for the architecture work captured in:
9
9
  - `docs/architecture/state-machine-transition-map.md`
10
10
  - `docs/architecture/state-machine-test-matrix.md`
11
11
  - `docs/architecture/state-machine-reducer-event-model.md`
12
- - `docs/architecture/goal-state-schema.md`
13
12
 
14
13
  The goal of this document is to explain:
15
14
 
@@ -95,7 +94,6 @@ Instead, it uses separate reducer families for:
95
94
  - loops
96
95
  - notifications
97
96
  - monitors
98
- - later, goals
99
97
 
100
98
  ### 2.5 Name coordination rules explicitly
101
99
 
@@ -203,14 +201,6 @@ This defines:
203
201
  - event vocabulary
204
202
  - coordinator rules
205
203
 
206
- ### 4.4 Goal schema
207
-
208
- `docs/architecture/goal-state-schema.md`
209
-
210
- This defines the future Goal layer as a reducer-owned, verification-oriented composition of existing reducer state.
211
-
212
- ---
213
-
214
204
  ## 5. Implemented extraction status
215
205
 
216
206
  At the time of this document, the following extractions are complete.
@@ -300,18 +290,6 @@ The coordinator currently exists as a reusable module with:
300
290
 
301
291
  It is not yet fully wired through the main runtime.
302
292
 
303
- ### 5.6 Goal verifier prototype
304
-
305
- Implemented:
306
-
307
- - `src/goal-types.ts`
308
- - `src/goal-verifier.ts`
309
- - `test/goal-verifier.test.ts`
310
-
311
- This is intentionally read-only and verification-oriented.
312
-
313
- ---
314
-
315
293
  ## 6. Current architecture after extraction
316
294
 
317
295
  The codebase now sits in an intermediate but useful state.
@@ -321,7 +299,6 @@ The codebase now sits in an intermediate but useful state.
321
299
  - entity state transitions are increasingly centralized in pure reducers
322
300
  - transition semantics are backed by focused reducer tests
323
301
  - scenario coverage exists for the highest-risk lifecycle behavior
324
- - goal work no longer needs to start from an undefined state model
325
302
 
326
303
  ### 6.2 What remains transitional
327
304
 
@@ -380,7 +357,6 @@ Reducers own logical state transitions for:
380
357
  - loops
381
358
  - notifications
382
359
  - monitors
383
- - goals
384
360
 
385
361
  ### 7.3 Coordinator
386
362
 
@@ -424,10 +400,6 @@ Suggested order:
424
400
 
425
401
  As each slice moves over, imperative code should become effect execution rather than state mutation.
426
402
 
427
- ### Step 4 — add a goal reducer
428
-
429
- Once the coordinator is meaningfully integrated, add a true `reduceGoalState(...)` layer rather than keeping goal work verifier-only.
430
-
431
403
  ### Step 5 — remove redundant imperative state paths
432
404
 
433
405
  Only after coordinator-owned dispatch is proven stable should old duplicated state paths be deleted.
@@ -476,20 +448,6 @@ Mitigation:
476
448
  - only reduce logical state
477
449
  - preserve stop/completion race tests
478
450
 
479
- ### 9.4 Goals can grow too ambitious too early
480
-
481
- The goal prototype is intentionally read-only.
482
-
483
- Risk:
484
-
485
- - adding autonomous write-side goal behavior before coordinator adoption is mature
486
-
487
- Mitigation:
488
-
489
- - keep goal verification read-only until reducer/coordinator integration is stable
490
-
491
- ---
492
-
493
451
  ## 10. Validation strategy
494
452
 
495
453
  The migration should continue to rely on three validation layers.
@@ -525,7 +483,6 @@ We now have:
525
483
  - a test contract
526
484
  - reducer modules for the four existing entity families
527
485
  - a coordinator abstraction
528
- - a goal schema and verifier prototype
529
486
 
530
487
  This means future work can be judged against a coherent architecture instead of a collection of local fixes.
531
488
 
@@ -541,6 +498,5 @@ The system is not yet at the final architecture. However, it now has the essenti
541
498
  - reducer state boundaries
542
499
  - effect boundaries
543
500
  - coordinator machinery
544
- - verification-oriented goal design
545
501
 
546
502
  The recommended next step, if further work continues, is to adopt the coordinator in narrow runtime slices rather than attempt a single large integration rewrite.
@@ -81,7 +81,7 @@ interface ReducerEvent<TType extends string = string, TPayload = unknown> {
81
81
  | "session"
82
82
  | "coordinator"
83
83
  | "system";
84
- entityType?: "task" | "loop" | "monitor" | "notification" | "goal";
84
+ entityType?: "task" | "loop" | "monitor" | "notification";
85
85
  entityId?: string;
86
86
  payload: TPayload;
87
87
  }
@@ -104,7 +104,7 @@ Reducers do not call Pi APIs directly. They emit effects.
104
104
  ```ts
105
105
  interface ReducerEffect<TEffect extends string = string, TPayload = unknown> {
106
106
  type: TEffect;
107
- entityType?: "task" | "loop" | "monitor" | "notification" | "goal";
107
+ entityType?: "task" | "loop" | "monitor" | "notification";
108
108
  entityId?: string;
109
109
  payload: TPayload;
110
110
  }
@@ -801,7 +801,6 @@ If an event cannot be tied to a transition/invariant/scenario row, it is probabl
801
801
  This event model does **not** yet define:
802
802
 
803
803
  - Goal reducer fields
804
- - persistent serialization format for goals
805
804
  - UI rendering model
806
805
  - task provider abstraction replacement
807
806
  - a generalized event sourcing log
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
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",
@@ -17,6 +17,11 @@
17
17
  "monitor",
18
18
  "scheduler"
19
19
  ],
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./api": "./src/api.ts",
23
+ "./package.json": "./package.json"
24
+ },
20
25
  "peerDependencies": {
21
26
  "@earendil-works/pi-coding-agent": "^0.75.5",
22
27
  "@earendil-works/pi-tui": "^0.75.5"
package/src/api.ts ADDED
@@ -0,0 +1,36 @@
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 {
7
+ type CleanReply,
8
+ type CreateTaskParams,
9
+ type CreateTaskReply,
10
+ type PendingReply,
11
+ type PingReply,
12
+ replyChannel,
13
+ type SpawnParams,
14
+ type SpawnReply,
15
+ SUBAGENTS_RPC,
16
+ TASK_EVENTS,
17
+ TASKS_RPC,
18
+ type TaskEntryWire,
19
+ type TaskStatusWire,
20
+ type UpdateTaskParams,
21
+ type UpdateTaskReply,
22
+ } from "./rpc/channels.js";
23
+ export {
24
+ type HandleRpcOptions,
25
+ handleRpc,
26
+ PROTOCOL_VERSION,
27
+ RpcError,
28
+ type RpcEventBus,
29
+ type RpcReply,
30
+ rpcCall,
31
+ rpcProbe,
32
+ } from "./rpc/cross-extension-rpc.js";
33
+ export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
34
+ export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
35
+ export { TaskStore } from "./task-store.js";
36
+ export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
@@ -85,7 +85,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
85
85
  if (!selected || selected === "< Back") return;
86
86
 
87
87
  const match = selected.match(/#(\d+)/);
88
- if (match) {
88
+ if (match?.[1]) {
89
89
  const entry = getStore().get(match[1]);
90
90
  if (entry) {
91
91
  const actions = ["x Delete"];
@@ -149,7 +149,7 @@ export function registerLoopCommand(options: LoopCommandOptions): void {
149
149
 
150
150
  const intervalMatch = trimmed.match(/^(\d+\s*[smhdS]\b)/i);
151
151
  if (intervalMatch) {
152
- const interval = intervalMatch[1];
152
+ const interval = intervalMatch[1] ?? intervalMatch[0];
153
153
  const prompt = trimmed.slice(intervalMatch[0].length).trim();
154
154
 
155
155
  if (!prompt) {
@@ -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
@@ -20,6 +20,7 @@ import { registerTasksCommand } from "./commands/tasks-command.js";
20
20
  import { atMaxFires } from "./loop-reducer.js";
21
21
  import { MonitorManager } from "./monitor-manager.js";
22
22
  import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
23
+ import { registerNativeTaskRpc } from "./runtime/native-task-rpc.js";
23
24
  import {
24
25
  createNotificationRuntime,
25
26
  type LoopFireEvent,
@@ -63,7 +64,9 @@ export default function (pi: ExtensionAPI) {
63
64
  // call), so stale monitors don't linger in the count between turns.
64
65
  monitorManager.setOnChange(() => widget.update());
65
66
  widget.setTaskSummaryProvider(() => {
66
- if (!nativeTaskStore) return { count: 0 };
67
+ // Once pi-tasks owns the channels, any native store created during the
68
+ // detection window is a shadow — never surface it in the status line.
69
+ if (tasksAvailable || !nativeTaskStore) return { count: 0 };
67
70
  const tasks = nativeTaskStore.list().filter(t => t.status === "pending" || t.status === "in_progress");
68
71
  const active = tasks.find(t => t.status === "in_progress");
69
72
  const next = tasks.find(t => t.status === "pending");
@@ -80,9 +83,19 @@ export default function (pi: ExtensionAPI) {
80
83
 
81
84
  // ── pi-tasks integration ──
82
85
  let tasksAvailable = false;
86
+ let tasksDetectionSettled = false;
83
87
  let nativeTaskStore: TaskStore | undefined;
84
88
  let nativeTasksRegistered = false;
85
89
 
90
+ function getOrCreateNativeTaskStore(): TaskStore | undefined {
91
+ // pi-tasks owns the task channels; don't create a shadow store.
92
+ if (tasksAvailable) return undefined;
93
+ if (!nativeTaskStore) {
94
+ nativeTaskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
95
+ }
96
+ return nativeTaskStore;
97
+ }
98
+
86
99
  const taskRuntime = createTaskRuntimeBridge({
87
100
  pi,
88
101
  isTasksAvailable: () => tasksAvailable,
@@ -97,6 +110,31 @@ export default function (pi: ExtensionAPI) {
97
110
  widget.update();
98
111
  await evaluateTaskBacklog(taskStore, taskStore.pendingCount());
99
112
  },
113
+ onDetectionStarted: () => {
114
+ tasksDetectionSettled = false;
115
+ },
116
+ onDetectionSettled: () => {
117
+ tasksDetectionSettled = true;
118
+ },
119
+ debug,
120
+ });
121
+
122
+ // The RPC server registers at init (not behind the 6s tool-fallback timer) so
123
+ // early cross-extension calls never race the timer; it stands down via
124
+ // isEnabled once an external pi-tasks is detected. checkTasksVersion ignores
125
+ // this server's own ping reply via its provider field. Mutating verbs stay
126
+ // silent until the detection probe settles, so a co-resident pi-tasks can
127
+ // never race the native server into creating divergent task state.
128
+ registerNativeTaskRpc({
129
+ pi,
130
+ getNativeTaskStore: getOrCreateNativeTaskStore,
131
+ isEnabled: () => !tasksAvailable,
132
+ isDetectionSettled: () => tasksDetectionSettled,
133
+ evaluateTaskBacklog: (taskStore, pendingCount) =>
134
+ evaluateTaskBacklog(taskStore, pendingCount),
135
+ updateWidget: () => {
136
+ widget.update();
137
+ },
100
138
  debug,
101
139
  });
102
140
 
@@ -173,6 +211,12 @@ export default function (pi: ExtensionAPI) {
173
211
  hasPendingTasks: () => hasPendingTasks(),
174
212
  bootstrapTaskLoop: (entry) => maybeBootstrapTaskLoop(entry),
175
213
  triggerHasEventSource,
214
+ emitLoopAutodeleted: (payload) => {
215
+ pi.events.emit("loops:autodeleted", payload);
216
+ },
217
+ emitTaskBacklogEmpty: (payload) => {
218
+ pi.events.emit("tasks:backlog_empty", payload);
219
+ },
176
220
  debug,
177
221
  });
178
222
 
@@ -301,9 +345,12 @@ export default function (pi: ExtensionAPI) {
301
345
 
302
346
  // ── Native task tools (only when pi-tasks is absent) ──
303
347
 
348
+ // Only tool/command registration stays deferred: the tool names collide with
349
+ // pi-tasks and cannot be unregistered. The RPC server is already live (above).
304
350
  const nativeTaskFallbackTimer = setTimeout(() => {
305
351
  if (tasksAvailable || nativeTasksRegistered) return;
306
- const taskStore = new TaskStore(resolveTaskStorePath(getScopeOptions(), _sessionId));
352
+ const taskStore = getOrCreateNativeTaskStore();
353
+ if (!taskStore) return;
307
354
 
308
355
  try {
309
356
  registerTasksCommand({
@@ -331,7 +378,6 @@ export default function (pi: ExtensionAPI) {
331
378
  throw error;
332
379
  }
333
380
 
334
- nativeTaskStore = taskStore;
335
381
  nativeTasksRegistered = true;
336
382
  debug("native task tools registered (pi-tasks not detected)");
337
383
  }, 6000);
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 {
@@ -60,8 +62,8 @@ export function parseInterval(input: string): { cron: string; description: strin
60
62
 
61
63
  const match = trimmed.match(/^(\d+)\s*(s|m|h|d)$/i);
62
64
  if (match) {
63
- const value = parseInt(match[1], 10);
64
- const unit = match[2].toLowerCase();
65
+ const value = parseInt(match[1] ?? "", 10);
66
+ const unit = (match[2] ?? "").toLowerCase();
65
67
  const totalSec = value * (UNIT_TO_CRON[unit] ?? 60);
66
68
 
67
69
  if (totalSec < 60) {
@@ -81,6 +83,15 @@ export function cronToNextFire(cronExpr: string, fromDate: Date = new Date()): D
81
83
  if (parts.length !== 5) throw new Error(`Invalid cron expression: ${cronExpr}`);
82
84
 
83
85
  const [minF, hourF, dayF, monthF, dowF] = parts;
86
+ if (
87
+ minF === undefined ||
88
+ hourF === undefined ||
89
+ dayF === undefined ||
90
+ monthF === undefined ||
91
+ dowF === undefined
92
+ ) {
93
+ throw new Error(`Invalid cron expression: ${cronExpr}`);
94
+ }
84
95
  const now = new Date(fromDate);
85
96
  now.setSeconds(0, 0);
86
97
 
@@ -107,7 +118,7 @@ function cronFieldMatches(field: string, value: number): boolean {
107
118
  if (part === "*") return true;
108
119
 
109
120
  if (part.includes("/")) {
110
- const [range, stepStr] = part.split("/");
121
+ const [range = "", stepStr = ""] = part.split("/");
111
122
  const step = parseInt(stepStr, 10);
112
123
  let rangeMin: number;
113
124
  let rangeMax: number;
@@ -116,7 +127,7 @@ function cronFieldMatches(field: string, value: number): boolean {
116
127
  rangeMin = 0;
117
128
  rangeMax = 59;
118
129
  } else if (range.includes("-")) {
119
- const [minS, maxS] = range.split("-");
130
+ const [minS = "", maxS = ""] = range.split("-");
120
131
  rangeMin = parseInt(minS, 10);
121
132
  rangeMax = parseInt(maxS, 10);
122
133
  } else {
@@ -132,7 +143,7 @@ function cronFieldMatches(field: string, value: number): boolean {
132
143
  }
133
144
 
134
145
  if (part.includes("-")) {
135
- const [minS, maxS] = part.split("-");
146
+ const [minS = "", maxS = ""] = part.split("-");
136
147
  const min = parseInt(minS, 10);
137
148
  const max = parseInt(maxS, 10);
138
149
  if (value >= min && value <= max) return true;
@@ -11,8 +11,8 @@ function acquireLock(lockPath: string): void {
11
11
  try {
12
12
  writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
13
13
  return;
14
- } catch (e: any) {
15
- if (e.code === "EEXIST") {
14
+ } catch (e) {
15
+ if ((e as NodeJS.ErrnoException).code === "EEXIST") {
16
16
  try {
17
17
  const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
18
18
  if (!pid || !isProcessRunning(pid)) {
@@ -60,7 +60,7 @@ export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
60
60
 
61
61
  /**
62
62
  * Shared persistence + reducer-dispatch machinery for the file-backed entity
63
- * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
63
+ * stores (loops, tasks). Owns file locking, signature-gated load, atomic
64
64
  * save, and reducer application; subclasses add only their entity-specific
65
65
  * command methods.
66
66
  *
@@ -0,0 +1,97 @@
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
+
5
+ /** Cross-extension RPC channels served by a tasks provider (pi-tasks or pi-loop native). */
6
+ export const TASKS_RPC = {
7
+ ping: "tasks:rpc:ping",
8
+ create: "tasks:rpc:create",
9
+ update: "tasks:rpc:update",
10
+ pending: "tasks:rpc:pending",
11
+ clean: "tasks:rpc:clean",
12
+ } as const;
13
+
14
+ /** Cross-extension RPC channels served by @tintinweb/pi-subagents. */
15
+ export const SUBAGENTS_RPC = {
16
+ ping: "subagents:rpc:ping",
17
+ spawn: "subagents:rpc:spawn",
18
+ stop: "subagents:rpc:stop",
19
+ } as const;
20
+
21
+ /** Broadcast (fire-and-forget) task lifecycle events. */
22
+ export const TASK_EVENTS = {
23
+ ready: "tasks:ready",
24
+ created: "tasks:created",
25
+ started: "tasks:started",
26
+ completed: "tasks:completed",
27
+ reopened: "tasks:reopened",
28
+ updated: "tasks:updated",
29
+ deleted: "tasks:deleted",
30
+ } as const;
31
+
32
+ export function replyChannel(channel: string, requestId: string): string {
33
+ return `${channel}:reply:${requestId}`;
34
+ }
35
+
36
+ // ── Wire-level DTOs ──
37
+ // Structural: pi-loop's TaskEntry satisfies TaskEntryWire; consumers on the
38
+ // other side of the bus depend only on these shapes, never on store internals.
39
+
40
+ export type TaskStatusWire = "pending" | "in_progress" | "completed";
41
+
42
+ export interface TaskEntryWire {
43
+ id: string;
44
+ subject: string;
45
+ description: string;
46
+ status: TaskStatusWire;
47
+ createdAt: number;
48
+ updatedAt: number;
49
+ completedAt?: number;
50
+ metadata?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface CreateTaskParams {
54
+ subject: string;
55
+ description: string;
56
+ metadata?: Record<string, unknown>;
57
+ }
58
+
59
+ export interface CreateTaskReply {
60
+ id: string;
61
+ task: TaskEntryWire;
62
+ }
63
+
64
+ export interface UpdateTaskParams {
65
+ id: string;
66
+ status?: TaskStatusWire;
67
+ subject?: string;
68
+ description?: string;
69
+ }
70
+
71
+ export interface UpdateTaskReply {
72
+ task: TaskEntryWire;
73
+ }
74
+
75
+ export interface PendingReply {
76
+ pending: number;
77
+ }
78
+
79
+ export interface CleanReply {
80
+ pruned: number;
81
+ }
82
+
83
+ export interface PingReply {
84
+ version: number;
85
+ /** Identifies which extension answered; lets a provider ignore its own reply. */
86
+ provider?: string;
87
+ }
88
+
89
+ export interface SpawnParams {
90
+ type: string;
91
+ prompt: string;
92
+ options?: Record<string, unknown>;
93
+ }
94
+
95
+ export interface SpawnReply {
96
+ id: string;
97
+ }