@trevonistrevon/pi-loop 0.5.9 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +64 -5
  3. package/dist/api.d.ts +11 -0
  4. package/dist/api.js +10 -0
  5. package/dist/commands/loop-command.d.ts +9 -2
  6. package/dist/commands/loop-command.js +81 -51
  7. package/dist/commands/tasks-command.js +3 -3
  8. package/dist/coordinator.d.ts +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +89 -19
  11. package/dist/loop-format.d.ts +3 -0
  12. package/dist/loop-format.js +20 -0
  13. package/dist/loop-parse.d.ts +1 -0
  14. package/dist/loop-parse.js +59 -6
  15. package/dist/loop-reducer.d.ts +13 -1
  16. package/dist/loop-reducer.js +26 -0
  17. package/dist/monitor-manager.js +1 -1
  18. package/dist/notification-reducer.d.ts +2 -0
  19. package/dist/reducer-backed-store.d.ts +1 -1
  20. package/dist/reducer-backed-store.js +1 -1
  21. package/dist/rpc/channels.d.ts +73 -0
  22. package/dist/rpc/channels.js +30 -0
  23. package/dist/rpc/cross-extension-rpc.d.ts +59 -0
  24. package/dist/rpc/cross-extension-rpc.js +117 -0
  25. package/dist/runtime/loop-events.d.ts +26 -0
  26. package/dist/runtime/loop-events.js +26 -0
  27. package/dist/runtime/monitor-ondone-runtime.js +1 -1
  28. package/dist/runtime/native-task-rpc.d.ts +22 -0
  29. package/dist/runtime/native-task-rpc.js +50 -0
  30. package/dist/runtime/notification-runtime.d.ts +2 -1
  31. package/dist/runtime/notification-runtime.js +19 -7
  32. package/dist/runtime/task-backlog-runtime.d.ts +5 -1
  33. package/dist/runtime/task-backlog-runtime.js +14 -7
  34. package/dist/runtime/task-mutations.d.ts +39 -0
  35. package/dist/runtime/task-mutations.js +68 -0
  36. package/dist/runtime/task-rpc.d.ts +4 -0
  37. package/dist/runtime/task-rpc.js +40 -58
  38. package/dist/scheduler.js +25 -7
  39. package/dist/store.d.ts +9 -1
  40. package/dist/store.js +57 -0
  41. package/dist/tools/loop-tools.d.ts +8 -0
  42. package/dist/tools/loop-tools.js +105 -15
  43. package/dist/tools/monitor-tools.js +1 -3
  44. package/dist/tools/native-task-tools.d.ts +2 -6
  45. package/dist/tools/native-task-tools.js +25 -55
  46. package/dist/tools/tool-result.d.ts +8 -0
  47. package/dist/tools/tool-result.js +4 -0
  48. package/dist/trigger-system.js +1 -1
  49. package/dist/types.d.ts +24 -1
  50. package/package.json +15 -2
  51. package/src/api.ts +36 -0
  52. package/src/commands/loop-command.ts +100 -55
  53. package/src/commands/tasks-command.ts +3 -3
  54. package/src/coordinator.ts +1 -1
  55. package/src/index.ts +92 -19
  56. package/src/loop-format.ts +22 -0
  57. package/src/loop-parse.ts +62 -8
  58. package/src/loop-reducer.ts +41 -1
  59. package/src/monitor-manager.ts +1 -1
  60. package/src/notification-reducer.ts +3 -0
  61. package/src/reducer-backed-store.ts +3 -3
  62. package/src/rpc/channels.ts +97 -0
  63. package/src/rpc/cross-extension-rpc.ts +152 -0
  64. package/src/runtime/loop-events.ts +60 -0
  65. package/src/runtime/monitor-ondone-runtime.ts +1 -1
  66. package/src/runtime/native-task-rpc.ts +139 -0
  67. package/src/runtime/notification-runtime.ts +21 -8
  68. package/src/runtime/task-backlog-runtime.ts +25 -7
  69. package/src/runtime/task-mutations.ts +103 -0
  70. package/src/runtime/task-rpc.ts +48 -54
  71. package/src/scheduler.ts +25 -8
  72. package/src/store.ts +59 -2
  73. package/src/tools/loop-tools.ts +129 -16
  74. package/src/tools/monitor-tools.ts +1 -4
  75. package/src/tools/native-task-tools.ts +31 -52
  76. package/src/tools/tool-result.ts +4 -0
  77. package/src/trigger-system.ts +5 -5
  78. package/src/types.ts +29 -1
  79. package/.release-please-manifest.json +0 -3
  80. package/AGENTS.md +0 -83
  81. package/CONTRIBUTING.md +0 -69
  82. package/Dockerfile +0 -17
  83. package/benchmarks/experiment-sim.js +0 -30
  84. package/coverage/base.css +0 -224
  85. package/coverage/block-navigation.js +0 -87
  86. package/coverage/coverage-final.json +0 -33
  87. package/coverage/favicon.png +0 -0
  88. package/coverage/index.html +0 -176
  89. package/coverage/prettify.css +0 -1
  90. package/coverage/prettify.js +0 -2
  91. package/coverage/sort-arrow-sprite.png +0 -0
  92. package/coverage/sorter.js +0 -210
  93. package/coverage/src/commands/index.html +0 -131
  94. package/coverage/src/commands/loop-command.ts.html +0 -634
  95. package/coverage/src/commands/tasks-command.ts.html +0 -490
  96. package/coverage/src/coordinator.ts.html +0 -430
  97. package/coverage/src/goal-coordinator.ts.html +0 -259
  98. package/coverage/src/goal-reducer.ts.html +0 -982
  99. package/coverage/src/goal-store.ts.html +0 -742
  100. package/coverage/src/goal-verifier.ts.html +0 -808
  101. package/coverage/src/index.html +0 -386
  102. package/coverage/src/index.ts.html +0 -1111
  103. package/coverage/src/loop-parse.ts.html +0 -574
  104. package/coverage/src/loop-reducer.ts.html +0 -559
  105. package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
  106. package/coverage/src/monitor-manager.ts.html +0 -940
  107. package/coverage/src/monitor-reducer.ts.html +0 -583
  108. package/coverage/src/notification-reducer.ts.html +0 -550
  109. package/coverage/src/reducer-backed-store.ts.html +0 -589
  110. package/coverage/src/runtime/index.html +0 -206
  111. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
  112. package/coverage/src/runtime/notification-runtime.ts.html +0 -721
  113. package/coverage/src/runtime/scope.ts.html +0 -196
  114. package/coverage/src/runtime/session-runtime.ts.html +0 -679
  115. package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -574
  116. package/coverage/src/runtime/task-events.ts.html +0 -208
  117. package/coverage/src/runtime/task-rpc.ts.html +0 -541
  118. package/coverage/src/scheduler.ts.html +0 -400
  119. package/coverage/src/store.ts.html +0 -667
  120. package/coverage/src/task-backlog-coordinator.ts.html +0 -181
  121. package/coverage/src/task-reducer.ts.html +0 -550
  122. package/coverage/src/task-store.ts.html +0 -526
  123. package/coverage/src/tools/index.html +0 -146
  124. package/coverage/src/tools/loop-tools.ts.html +0 -1000
  125. package/coverage/src/tools/monitor-tools.ts.html +0 -547
  126. package/coverage/src/tools/native-task-tools.ts.html +0 -556
  127. package/coverage/src/trigger-system.ts.html +0 -547
  128. package/coverage/src/ui/index.html +0 -116
  129. package/coverage/src/ui/widget.ts.html +0 -292
  130. package/dist/goal-coordinator.d.ts +0 -22
  131. package/dist/goal-coordinator.js +0 -28
  132. package/dist/goal-reducer.d.ts +0 -98
  133. package/dist/goal-reducer.js +0 -190
  134. package/dist/goal-store.d.ts +0 -22
  135. package/dist/goal-store.js +0 -188
  136. package/dist/goal-types.d.ts +0 -82
  137. package/dist/goal-types.js +0 -1
  138. package/dist/goal-verifier.d.ts +0 -20
  139. package/dist/goal-verifier.js +0 -198
  140. package/docker-compose.yml +0 -12
  141. package/docs/architecture/goal-state-schema.md +0 -505
  142. package/docs/architecture/state-machine-migration.md +0 -546
  143. package/docs/architecture/state-machine-reducer-event-model.md +0 -823
  144. package/docs/architecture/state-machine-test-matrix.md +0 -249
  145. package/docs/architecture/state-machine-transition-map.md +0 -436
  146. package/release-please-config.json +0 -9
  147. package/src/goal-coordinator.ts +0 -58
  148. package/src/goal-reducer.ts +0 -299
  149. package/src/goal-store.ts +0 -219
  150. package/src/goal-types.ts +0 -104
  151. package/src/goal-verifier.ts +0 -241
  152. package/vitest.config.ts +0 -29
@@ -0,0 +1,117 @@
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: 2
4
+ import { randomUUID } from "node:crypto";
5
+ import { replyChannel } from "./channels.js";
6
+ /**
7
+ * Cross-extension request/reply over the in-process pi event bus.
8
+ *
9
+ * Wire contract (matches the pi-mono convention used by @tintinweb/pi-subagents):
10
+ * request: emit(channel, { requestId, ...params })
11
+ * reply: emit(`${channel}:reply:${requestId}`, RpcReply)
12
+ */
13
+ export const PROTOCOL_VERSION = 2;
14
+ export class RpcError extends Error {
15
+ channel;
16
+ timedOut;
17
+ constructor(channel, message, timedOut = false) {
18
+ super(message);
19
+ this.channel = channel;
20
+ this.timedOut = timedOut;
21
+ this.name = "RpcError";
22
+ }
23
+ }
24
+ /**
25
+ * Call a remote handler and await its reply.
26
+ *
27
+ * Rejects with RpcError on a failure envelope or on timeout (timedOut=true).
28
+ * Callers that treat failure as "fall back" wrap this in try/catch at the
29
+ * layer that knows the fallback — the RPC layer itself never returns sentinels.
30
+ */
31
+ export function rpcCall(bus, channel, params = {}, timeoutMs = 5000) {
32
+ const requestId = randomUUID();
33
+ return new Promise((resolve, reject) => {
34
+ let settled = false;
35
+ const timer = setTimeout(() => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ unsub();
40
+ reject(new RpcError(channel, `${channel} timed out after ${timeoutMs}ms`, true));
41
+ }, timeoutMs);
42
+ const unsub = bus.on(replyChannel(channel, requestId), (raw) => {
43
+ if (settled)
44
+ return;
45
+ settled = true;
46
+ clearTimeout(timer);
47
+ unsub();
48
+ const reply = raw;
49
+ if (reply?.success) {
50
+ resolve(reply.data);
51
+ }
52
+ else {
53
+ const error = reply && "error" in reply ? reply.error : undefined;
54
+ reject(new RpcError(channel, error ?? `${channel} replied with a malformed envelope`));
55
+ }
56
+ });
57
+ try {
58
+ bus.emit(channel, { requestId, ...params });
59
+ }
60
+ catch (error) {
61
+ if (!settled) {
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ unsub();
65
+ reject(error);
66
+ }
67
+ }
68
+ });
69
+ }
70
+ /**
71
+ * Detection probe: resolves undefined when nobody answers the ping channel.
72
+ * Absence of a provider is expected, not an error.
73
+ *
74
+ * First reply wins. If the probing extension also serves this channel, its
75
+ * own reply will settle the probe — in that case listen on the reply channel
76
+ * directly for the full window and filter by PingReply.provider instead.
77
+ */
78
+ export async function rpcProbe(bus, pingChannel, timeoutMs = 5000) {
79
+ try {
80
+ return await rpcCall(bus, pingChannel, {}, timeoutMs);
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ /**
87
+ * Register a server handler for one RPC channel. Returns the unsubscribe fn.
88
+ *
89
+ * Malformed requests get a failure reply, never a silent drop — the only
90
+ * silent case is a missing requestId, which leaves no reply address.
91
+ * A thrown/rejected fn becomes a failure envelope.
92
+ */
93
+ export function handleRpc(bus, channel, fn, opts) {
94
+ return bus.on(channel, async (raw) => {
95
+ if (opts?.enabled && !opts.enabled())
96
+ return;
97
+ const requestId = raw && typeof raw === "object"
98
+ ? raw.requestId
99
+ : undefined;
100
+ if (typeof requestId !== "string" || requestId.length === 0) {
101
+ opts?.debug?.(`${channel} — dropped request without requestId`);
102
+ return;
103
+ }
104
+ let reply;
105
+ try {
106
+ const result = await fn(raw);
107
+ reply = result === undefined ? { success: true } : { success: true, data: result };
108
+ }
109
+ catch (error) {
110
+ reply = {
111
+ success: false,
112
+ error: error instanceof Error ? error.message : String(error),
113
+ };
114
+ }
115
+ bus.emit(replyChannel(channel, requestId), reply);
116
+ });
117
+ }
@@ -0,0 +1,26 @@
1
+ import type { LoopEntry } from "../types.js";
2
+ export type LoopAutoDeleteReason = "task_backlog_empty";
3
+ export interface LoopAutodeletedPayload {
4
+ loopId: string;
5
+ prompt: string;
6
+ trigger: LoopEntry["trigger"];
7
+ recurring: boolean;
8
+ autoTask?: boolean;
9
+ taskBacklog?: boolean;
10
+ readOnly?: boolean;
11
+ maxFires?: number;
12
+ fireCount?: number;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ expiresAt: number;
16
+ source: "task_backlog_runtime";
17
+ reason: LoopAutoDeleteReason;
18
+ pendingCount: number;
19
+ }
20
+ export interface TaskBacklogEmptyPayload {
21
+ pendingCount: 0;
22
+ deletedLoopIds: string[];
23
+ source: "task_backlog_runtime";
24
+ }
25
+ export declare function buildLoopAutodeletedPayload(entry: LoopEntry, pendingCount: number): LoopAutodeletedPayload;
26
+ export declare function buildTaskBacklogEmptyPayload(deletedLoopIds: string[]): TaskBacklogEmptyPayload;
@@ -0,0 +1,26 @@
1
+ export function buildLoopAutodeletedPayload(entry, pendingCount) {
2
+ return {
3
+ loopId: entry.id,
4
+ prompt: entry.prompt,
5
+ trigger: entry.trigger,
6
+ recurring: entry.recurring,
7
+ autoTask: entry.autoTask,
8
+ taskBacklog: entry.taskBacklog,
9
+ readOnly: entry.readOnly,
10
+ maxFires: entry.maxFires,
11
+ fireCount: entry.fireCount,
12
+ createdAt: entry.createdAt,
13
+ updatedAt: entry.updatedAt,
14
+ expiresAt: entry.expiresAt,
15
+ source: "task_backlog_runtime",
16
+ reason: "task_backlog_empty",
17
+ pendingCount,
18
+ };
19
+ }
20
+ export function buildTaskBacklogEmptyPayload(deletedLoopIds) {
21
+ return {
22
+ pendingCount: 0,
23
+ deletedLoopIds,
24
+ source: "task_backlog_runtime",
25
+ };
26
+ }
@@ -37,7 +37,7 @@ export function createMonitorOnDoneRuntime(options) {
37
37
  return;
38
38
  const monitor = monitorManager.get(monitorId);
39
39
  if (monitor && monitor.status !== "running") {
40
- if (monitor.status === "completed") {
40
+ if (monitor.status === "completed" || monitor.status === "error") {
41
41
  deliver();
42
42
  return;
43
43
  }
@@ -0,0 +1,22 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskStore } from "../task-store.js";
3
+ import { type TaskBacklogResult } from "./task-mutations.js";
4
+ /** Discriminates pi-loop's own ping replies from an external pi-tasks provider. */
5
+ export declare const NATIVE_TASKS_PROVIDER = "pi-loop-native";
6
+ export interface NativeTaskRpcOptions {
7
+ pi: ExtensionAPI;
8
+ getNativeTaskStore: () => TaskStore | undefined;
9
+ /** Stands the server down (silent no-op) when an external pi-tasks owns the channels. */
10
+ isEnabled?: () => boolean;
11
+ /**
12
+ * Gates the mutating/reading verbs (create/update/pending/clean) until the
13
+ * pi-tasks detection probe has settled. Ping stays answerable throughout so
14
+ * providers remain discoverable, but no state diverges while an external
15
+ * provider might still claim the channels. Defaults to settled.
16
+ */
17
+ isDetectionSettled?: () => boolean;
18
+ evaluateTaskBacklog: (taskStore: TaskStore, pendingCount: number) => Promise<TaskBacklogResult>;
19
+ updateWidget: () => void;
20
+ debug?: (...args: unknown[]) => void;
21
+ }
22
+ export declare function registerNativeTaskRpc(options: NativeTaskRpcOptions): void;
@@ -0,0 +1,50 @@
1
+ import { TASKS_RPC, } from "../rpc/channels.js";
2
+ import { handleRpc, PROTOCOL_VERSION } from "../rpc/cross-extension-rpc.js";
3
+ import { cleanTasks, createTask, updateTask, } from "./task-mutations.js";
4
+ /** Discriminates pi-loop's own ping replies from an external pi-tasks provider. */
5
+ export const NATIVE_TASKS_PROVIDER = "pi-loop-native";
6
+ export function registerNativeTaskRpc(options) {
7
+ const { pi, getNativeTaskStore, isEnabled, isDetectionSettled, evaluateTaskBacklog, updateWidget, debug, } = options;
8
+ const rpcOpts = { enabled: isEnabled, debug };
9
+ const settledRpcOpts = {
10
+ enabled: () => (isEnabled ? isEnabled() : true) && (isDetectionSettled ? isDetectionSettled() : true),
11
+ debug,
12
+ };
13
+ function requireMutationContext() {
14
+ const taskStore = getNativeTaskStore();
15
+ if (!taskStore)
16
+ throw new Error("native task store unavailable");
17
+ return { pi, taskStore, evaluateTaskBacklog, updateWidget };
18
+ }
19
+ handleRpc(pi.events, TASKS_RPC.ping, () => ({ version: PROTOCOL_VERSION, provider: NATIVE_TASKS_PROVIDER }), rpcOpts);
20
+ handleRpc(pi.events, TASKS_RPC.pending, () => ({ pending: requireMutationContext().taskStore.pendingCount() }), settledRpcOpts);
21
+ handleRpc(pi.events, TASKS_RPC.create, async (request) => {
22
+ if (!request.subject || !request.description) {
23
+ throw new Error("subject and description are required");
24
+ }
25
+ const { entry } = await createTask(requireMutationContext(), {
26
+ subject: request.subject,
27
+ description: request.description,
28
+ metadata: request.metadata,
29
+ });
30
+ return { id: entry.id, task: entry };
31
+ }, settledRpcOpts);
32
+ handleRpc(pi.events, TASKS_RPC.clean, async () => {
33
+ const pruned = await cleanTasks(requireMutationContext());
34
+ debug?.(`${TASKS_RPC.clean} — pruned ${pruned} completed task(s)`);
35
+ return { pruned };
36
+ }, settledRpcOpts);
37
+ handleRpc(pi.events, TASKS_RPC.update, async (request) => {
38
+ if (!request.id)
39
+ throw new Error("id is required");
40
+ const result = await updateTask(requireMutationContext(), {
41
+ id: request.id,
42
+ status: request.status,
43
+ subject: request.subject,
44
+ description: request.description,
45
+ });
46
+ if (!result)
47
+ throw new Error(`Task #${request.id} not found`);
48
+ return { task: result.entry };
49
+ }, settledRpcOpts);
50
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { Trigger } from "../types.js";
2
+ import type { DynamicLoopState, Trigger } from "../types.js";
3
3
  export interface LoopFireEvent {
4
4
  loopId: string;
5
5
  prompt: string;
@@ -8,6 +8,7 @@ export interface LoopFireEvent {
8
8
  readOnly?: boolean;
9
9
  recurring?: boolean;
10
10
  autoTask?: boolean;
11
+ dynamic?: DynamicLoopState;
11
12
  }
12
13
  export interface PendingNotification extends LoopFireEvent {
13
14
  key: string;
@@ -1,4 +1,5 @@
1
1
  import { createCoordinator, } from "../coordinator.js";
2
+ import { formatTrigger } from "../loop-format.js";
2
3
  import { reduceNotificationState, } from "../notification-reducer.js";
3
4
  export function createNotificationRuntime(options) {
4
5
  const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
@@ -43,18 +44,28 @@ export function createNotificationRuntime(options) {
43
44
  });
44
45
  }
45
46
  function buildLoopFireMessage(data) {
46
- const triggerInfo = typeof data.trigger === "string"
47
- ? data.trigger
48
- : data.trigger?.type === "cron"
49
- ? `schedule: ${data.trigger.schedule}`
50
- : data.trigger?.type === "event"
51
- ? `event: ${data.trigger.source}`
52
- : "hybrid";
47
+ const triggerInfo = formatTrigger(data.trigger, "notification");
53
48
  const loopId = data.loopId || "?";
54
49
  const prompt = data.prompt || "loop fired";
55
50
  const constraint = data.readOnly
56
51
  ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
57
52
  : "";
53
+ if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
54
+ const dynamic = data.dynamic;
55
+ const lines = [
56
+ `[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
57
+ `Goal: ${dynamic?.goal ?? prompt}`,
58
+ `Iteration: ${dynamic?.iteration ?? 0}`,
59
+ ];
60
+ if (dynamic?.state)
61
+ lines.push(`State: ${dynamic.state}`);
62
+ if (dynamic?.metrics)
63
+ lines.push(`Metrics: ${dynamic.metrics}`);
64
+ if (dynamic?.doneCriteria)
65
+ lines.push(`Done criteria: ${dynamic.doneCriteria}`);
66
+ lines.push("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
67
+ return lines.join("\n");
68
+ }
58
69
  return [
59
70
  `[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
60
71
  prompt,
@@ -88,6 +99,7 @@ export function createNotificationRuntime(options) {
88
99
  recurring: notification.recurring,
89
100
  readOnly: notification.readOnly,
90
101
  autoTask: notification.autoTask,
102
+ dynamic: notification.dynamic,
91
103
  timestamp: notification.timestamp,
92
104
  },
93
105
  }, {
@@ -1,5 +1,6 @@
1
1
  import type { TaskStore } from "../task-store.js";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
3
+ import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
3
4
  export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
4
5
  export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
5
6
  export interface TaskBacklogRuntimeOptions {
@@ -10,12 +11,15 @@ export interface TaskBacklogRuntimeOptions {
10
11
  maxFires?: number;
11
12
  }) => LoopEntry;
12
13
  deleteLoop: (id: string) => void;
14
+ recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
13
15
  addTrigger: (entry: LoopEntry) => void;
14
16
  removeTrigger: (id: string) => void;
15
17
  updateWidget: () => void;
16
18
  hasPendingTasks: () => Promise<number>;
17
19
  bootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
18
20
  triggerHasEventSource: (trigger: Trigger | string, source: string) => boolean;
21
+ emitLoopAutodeleted?: (payload: LoopAutodeletedPayload) => void;
22
+ emitTaskBacklogEmpty?: (payload: TaskBacklogEmptyPayload) => void;
19
23
  debug?: (...args: unknown[]) => void;
20
24
  }
21
25
  export interface TaskBacklogRuntime {
@@ -1,9 +1,10 @@
1
1
  import { createCoordinator, } from "../coordinator.js";
2
2
  import { reduceTaskBacklogEvent, } from "../task-backlog-coordinator.js";
3
+ import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./loop-events.js";
3
4
  export const AUTO_TASK_WORKER_THRESHOLD = 5;
4
5
  export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
5
6
  export function createTaskBacklogRuntime(options) {
6
- const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, debug, } = options;
7
+ const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
7
8
  function isAutoTaskWorkerLoop(entry) {
8
9
  return entry.status === "active"
9
10
  && entry.prompt === AUTO_TASK_WORKER_PROMPT
@@ -17,6 +18,13 @@ export function createTaskBacklogRuntime(options) {
17
18
  function findAutoTaskWorkerLoop() {
18
19
  return getLoops().find(isAutoTaskWorkerLoop);
19
20
  }
21
+ function deleteTaskBacklogLoop(entry, pendingCount) {
22
+ debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
23
+ removeTrigger(entry.id);
24
+ recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
25
+ deleteLoop(entry.id);
26
+ emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
27
+ }
20
28
  async function cleanupTaskBacklogLoops() {
21
29
  const backlogLoops = getLoops().filter(isTaskBacklogLoop);
22
30
  if (backlogLoops.length === 0)
@@ -24,11 +32,10 @@ export function createTaskBacklogRuntime(options) {
24
32
  const pending = await hasPendingTasks();
25
33
  if (pending < 0 || pending > 0)
26
34
  return 0;
27
- for (const entry of backlogLoops) {
28
- debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
29
- removeTrigger(entry.id);
30
- deleteLoop(entry.id);
31
- }
35
+ const deletedLoopIds = backlogLoops.map((entry) => entry.id);
36
+ emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
37
+ for (const entry of backlogLoops)
38
+ deleteTaskBacklogLoop(entry, pending);
32
39
  updateWidget();
33
40
  return backlogLoops.length;
34
41
  }
@@ -40,7 +47,7 @@ export function createTaskBacklogRuntime(options) {
40
47
  return { entry: existing, created: false };
41
48
  const trigger = {
42
49
  type: "hybrid",
43
- cron: "*/5 * * * *",
50
+ cron: "*/3 * * * *",
44
51
  event: { source: "tasks:created" },
45
52
  debounceMs: 30000,
46
53
  };
@@ -0,0 +1,39 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TaskStore } from "../task-store.js";
3
+ import type { TaskEntry, TaskStatus } from "../task-types.js";
4
+ export interface TaskBacklogResult {
5
+ created: boolean;
6
+ entry?: {
7
+ id: string;
8
+ };
9
+ cleaned?: number;
10
+ }
11
+ export interface TaskMutationContext {
12
+ pi: ExtensionAPI;
13
+ taskStore: TaskStore;
14
+ evaluateTaskBacklog: (taskStore: TaskStore, pendingCount: number) => Promise<TaskBacklogResult>;
15
+ updateWidget: () => void;
16
+ }
17
+ export interface UpdateTaskFields {
18
+ id: string;
19
+ status?: TaskStatus;
20
+ subject?: string;
21
+ description?: string;
22
+ }
23
+ export declare function createTask(ctx: TaskMutationContext, params: {
24
+ subject: string;
25
+ description: string;
26
+ metadata?: Record<string, unknown>;
27
+ }): Promise<{
28
+ entry: TaskEntry;
29
+ backlog: TaskBacklogResult;
30
+ }>;
31
+ export declare function updateTask(ctx: TaskMutationContext, params: UpdateTaskFields): Promise<{
32
+ entry: TaskEntry;
33
+ backlog: TaskBacklogResult;
34
+ } | undefined>;
35
+ export declare function deleteTask(ctx: TaskMutationContext, id: string): Promise<{
36
+ entry: TaskEntry;
37
+ backlog: TaskBacklogResult;
38
+ } | undefined>;
39
+ export declare function cleanTasks(ctx: TaskMutationContext): Promise<number>;
@@ -0,0 +1,68 @@
1
+ import { emitNativeTaskEvent } from "./task-events.js";
2
+ /**
3
+ * Single source of truth for native task mutations, shared by the TaskCreate/
4
+ * TaskUpdate/TaskDelete tools and the tasks:rpc:* server so both surfaces emit
5
+ * identical events in one canonical order: mutate → emit → widget → backlog.
6
+ */
7
+ async function settle(ctx) {
8
+ ctx.updateWidget();
9
+ return await ctx.evaluateTaskBacklog(ctx.taskStore, ctx.taskStore.pendingCount());
10
+ }
11
+ export async function createTask(ctx, params) {
12
+ const entry = ctx.taskStore.create(params.subject, params.description, params.metadata);
13
+ emitNativeTaskEvent(ctx.pi, "tasks:created", entry);
14
+ const backlog = await settle(ctx);
15
+ return { entry, backlog };
16
+ }
17
+ export async function updateTask(ctx, params) {
18
+ const { id, status, subject, description } = params;
19
+ let entry = ctx.taskStore.get(id);
20
+ if (!entry)
21
+ return undefined;
22
+ const previousStatus = entry.status;
23
+ if (status === "in_progress") {
24
+ entry = ctx.taskStore.start(id);
25
+ if (entry)
26
+ emitNativeTaskEvent(ctx.pi, "tasks:started", entry, previousStatus);
27
+ }
28
+ else if (status === "completed") {
29
+ entry = ctx.taskStore.complete(id);
30
+ if (entry)
31
+ emitNativeTaskEvent(ctx.pi, "tasks:completed", entry, previousStatus);
32
+ }
33
+ else if (status === "pending") {
34
+ entry = ctx.taskStore.reopen(id);
35
+ if (entry)
36
+ emitNativeTaskEvent(ctx.pi, "tasks:reopened", entry, previousStatus);
37
+ }
38
+ if (!entry)
39
+ return undefined;
40
+ if (subject !== undefined || description !== undefined) {
41
+ // A details edit is not a transition: its previousStatus is the status
42
+ // current at edit time (i.e. after any transition above), so consumers
43
+ // never see a fabricated second transition.
44
+ const statusAtEdit = entry.status;
45
+ entry = ctx.taskStore.updateDetails(id, { subject, description });
46
+ if (entry)
47
+ emitNativeTaskEvent(ctx.pi, "tasks:updated", entry, statusAtEdit);
48
+ }
49
+ if (!entry)
50
+ return undefined;
51
+ const backlog = await settle(ctx);
52
+ return { entry, backlog };
53
+ }
54
+ export async function deleteTask(ctx, id) {
55
+ const existing = ctx.taskStore.get(id);
56
+ if (!existing || !ctx.taskStore.delete(id)) {
57
+ ctx.updateWidget();
58
+ return undefined;
59
+ }
60
+ emitNativeTaskEvent(ctx.pi, "tasks:deleted", existing, existing.status);
61
+ const backlog = await settle(ctx);
62
+ return { entry: existing, backlog };
63
+ }
64
+ export async function cleanTasks(ctx) {
65
+ const pruned = ctx.taskStore.pruneCompleted();
66
+ await settle(ctx);
67
+ return pruned;
68
+ }
@@ -8,6 +8,10 @@ export interface TaskRuntimeBridgeOptions {
8
8
  getNativeTaskStore: () => TaskStore | undefined;
9
9
  onNativeTaskCreated?: (taskStore: TaskStore) => void;
10
10
  onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
11
+ /** Called when a detection window opens. */
12
+ onDetectionStarted?: () => void;
13
+ /** Called when a detection window closes (provider found or probe timed out). */
14
+ onDetectionSettled?: () => void;
11
15
  debug?: (...args: unknown[]) => void;
12
16
  }
13
17
  export interface TaskRuntimeBridge {
@@ -1,49 +1,54 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { replyChannel, TASKS_RPC, } from "../rpc/channels.js";
3
+ import { rpcCall } from "../rpc/cross-extension-rpc.js";
4
+ import { NATIVE_TASKS_PROVIDER } from "./native-task-rpc.js";
2
5
  import { emitNativeTaskEvent } from "./task-events.js";
3
6
  export function createTaskRuntimeBridge(options) {
4
- const { pi, isTasksAvailable, setTasksAvailable, getNativeTaskStore, onNativeTaskCreated, onNativeTasksPruned, debug, } = options;
7
+ const { pi, isTasksAvailable, setTasksAvailable, getNativeTaskStore, onNativeTaskCreated, onNativeTasksPruned, onDetectionStarted, onDetectionSettled, debug, } = options;
8
+ let detectionEpoch = 0;
5
9
  function checkTasksVersion() {
10
+ // Not rpcProbe: pi-loop's own native server also answers this ping, and a
11
+ // first-reply-wins probe would always settle on that self-reply. Keep the
12
+ // listener open for the whole window and skip self-replies so a slower
13
+ // external provider (pi-tasks) is still detected.
14
+ const epoch = ++detectionEpoch;
15
+ onDetectionStarted?.();
16
+ const settleCurrentDetection = () => {
17
+ if (epoch !== detectionEpoch)
18
+ return;
19
+ onDetectionSettled?.();
20
+ };
6
21
  const requestId = randomUUID();
7
22
  const timer = setTimeout(() => {
8
23
  unsub();
24
+ settleCurrentDetection();
9
25
  }, 5000);
10
- const unsub = pi.events.on(`tasks:rpc:ping:reply:${requestId}`, (raw) => {
26
+ const unsub = pi.events.on(replyChannel(TASKS_RPC.ping, requestId), (raw) => {
27
+ const reply = raw;
28
+ if (!reply?.success || !reply.data)
29
+ return;
30
+ if (reply.data.provider === NATIVE_TASKS_PROVIDER)
31
+ return;
32
+ if (reply.data.version === undefined)
33
+ return;
11
34
  unsub();
12
35
  clearTimeout(timer);
13
- const remoteVersion = raw?.data?.version;
14
- if (remoteVersion !== undefined)
15
- setTasksAvailable(true);
36
+ setTasksAvailable(true);
37
+ settleCurrentDetection();
16
38
  });
17
- pi.events.emit("tasks:rpc:ping", { requestId });
39
+ pi.events.emit(TASKS_RPC.ping, { requestId });
18
40
  }
19
41
  async function autoCreateTask(entry) {
20
42
  if (!entry.autoTask)
21
43
  return undefined;
22
44
  if (isTasksAvailable()) {
23
45
  try {
24
- const requestId = randomUUID();
25
- const taskId = await new Promise((resolve) => {
26
- const timer = setTimeout(() => {
27
- unsub();
28
- resolve(undefined);
29
- }, 5000);
30
- const unsub = pi.events.on(`tasks:rpc:create:reply:${requestId}`, (raw) => {
31
- unsub();
32
- clearTimeout(timer);
33
- const reply = raw;
34
- if (reply.success && reply.data)
35
- resolve(reply.data.id);
36
- else
37
- resolve(undefined);
38
- });
39
- pi.events.emit("tasks:rpc:create", {
40
- requestId,
41
- subject: entry.prompt.slice(0, 80),
42
- description: `Auto-created from loop #${entry.id}`,
43
- metadata: { loopId: entry.id, trigger: entry.trigger },
44
- });
45
- });
46
- return taskId;
46
+ const reply = await rpcCall(pi.events, TASKS_RPC.create, {
47
+ subject: entry.prompt.slice(0, 80),
48
+ description: `Auto-created from loop #${entry.id}`,
49
+ metadata: { loopId: entry.id, trigger: entry.trigger },
50
+ }, 5000);
51
+ return reply.id;
47
52
  }
48
53
  catch {
49
54
  return undefined;
@@ -59,22 +64,11 @@ export function createTaskRuntimeBridge(options) {
59
64
  }
60
65
  async function hasPendingTasks() {
61
66
  if (isTasksAvailable()) {
67
+ // -1 is this bridge's "unknown" sentinel, consumed by notification-runtime;
68
+ // the RPC layer itself rejects on failure.
62
69
  try {
63
- const requestId = randomUUID();
64
- const count = await new Promise((resolve) => {
65
- const timer = setTimeout(() => {
66
- unsub();
67
- resolve(-1);
68
- }, 3000);
69
- const unsub = pi.events.on(`tasks:rpc:pending:reply:${requestId}`, (raw) => {
70
- unsub();
71
- clearTimeout(timer);
72
- const reply = raw;
73
- resolve(reply.success && reply.data ? reply.data.pending : -1);
74
- });
75
- pi.events.emit("tasks:rpc:pending", { requestId });
76
- });
77
- return count;
70
+ const reply = await rpcCall(pi.events, TASKS_RPC.pending, {}, 3000);
71
+ return reply.pending;
78
72
  }
79
73
  catch {
80
74
  return -1;
@@ -85,20 +79,8 @@ export function createTaskRuntimeBridge(options) {
85
79
  async function cleanDoneTasks() {
86
80
  if (isTasksAvailable()) {
87
81
  try {
88
- const requestId = randomUUID();
89
- await new Promise((resolve) => {
90
- const timer = setTimeout(() => {
91
- unsub();
92
- resolve();
93
- }, 3000);
94
- const unsub = pi.events.on(`tasks:rpc:clean:reply:${requestId}`, () => {
95
- unsub();
96
- clearTimeout(timer);
97
- debug?.("tasks:rpc:clean — done tasks swept");
98
- resolve();
99
- });
100
- pi.events.emit("tasks:rpc:clean", { requestId });
101
- });
82
+ await rpcCall(pi.events, TASKS_RPC.clean, {}, 3000);
83
+ debug?.(`${TASKS_RPC.clean} done tasks swept`);
102
84
  }
103
85
  catch {
104
86
  // timeout or error, ignore