@trevonistrevon/pi-loop 0.4.11 → 0.5.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.
- package/README.md +20 -9
- package/dist/commands/loop-command.d.ts +22 -0
- package/dist/commands/loop-command.js +148 -0
- package/dist/commands/tasks-command.d.ts +15 -0
- package/dist/commands/tasks-command.js +117 -0
- package/dist/coordinator.d.ts +35 -0
- package/dist/coordinator.js +56 -0
- package/dist/goal-coordinator.d.ts +22 -0
- package/dist/goal-coordinator.js +28 -0
- package/dist/goal-reducer.d.ts +89 -0
- package/dist/goal-reducer.js +181 -0
- package/dist/goal-store.d.ts +31 -0
- package/dist/goal-store.js +298 -0
- package/dist/goal-types.d.ts +82 -0
- package/dist/goal-types.js +1 -0
- package/dist/goal-verifier.d.ts +20 -0
- package/dist/goal-verifier.js +198 -0
- package/dist/index.js +130 -1087
- package/dist/loop-reducer.d.ts +63 -0
- package/dist/loop-reducer.js +67 -0
- package/dist/monitor-completion-coordinator.d.ts +10 -0
- package/dist/monitor-completion-coordinator.js +13 -0
- package/dist/monitor-manager.d.ts +2 -0
- package/dist/monitor-manager.js +107 -29
- package/dist/monitor-reducer.d.ts +82 -0
- package/dist/monitor-reducer.js +69 -0
- package/dist/notification-reducer.d.ts +81 -0
- package/dist/notification-reducer.js +65 -0
- package/dist/runtime/monitor-ondone-runtime.d.ts +13 -0
- package/dist/runtime/monitor-ondone-runtime.js +49 -0
- package/dist/runtime/notification-runtime.d.ts +34 -0
- package/dist/runtime/notification-runtime.js +152 -0
- package/dist/runtime/scope.d.ts +8 -0
- package/dist/runtime/scope.js +33 -0
- package/dist/runtime/session-runtime.d.ts +39 -0
- package/dist/runtime/session-runtime.js +110 -0
- package/dist/runtime/task-backlog-runtime.d.ts +36 -0
- package/dist/runtime/task-backlog-runtime.js +105 -0
- package/dist/runtime/task-rpc.d.ts +19 -0
- package/dist/runtime/task-rpc.js +118 -0
- package/dist/store.d.ts +7 -4
- package/dist/store.js +129 -49
- package/dist/task-backlog-coordinator.d.ts +12 -0
- package/dist/task-backlog-coordinator.js +22 -0
- package/dist/task-reducer.d.ts +66 -0
- package/dist/task-reducer.js +76 -0
- package/dist/task-store.d.ts +8 -4
- package/dist/task-store.js +102 -33
- package/dist/tools/loop-tools.d.ts +41 -0
- package/dist/tools/loop-tools.js +241 -0
- package/dist/tools/monitor-tools.d.ts +25 -0
- package/dist/tools/monitor-tools.js +110 -0
- package/dist/tools/native-task-tools.d.ts +15 -0
- package/dist/tools/native-task-tools.js +127 -0
- package/docs/architecture/goal-state-schema.md +505 -0
- package/docs/architecture/state-machine-migration.md +546 -0
- package/docs/architecture/state-machine-reducer-event-model.md +823 -0
- package/docs/architecture/state-machine-test-matrix.md +249 -0
- package/docs/architecture/state-machine-transition-map.md +436 -0
- package/package.json +1 -1
- package/src/commands/loop-command.ts +184 -0
- package/src/commands/tasks-command.ts +135 -0
- package/src/coordinator.ts +115 -0
- package/src/goal-coordinator.ts +58 -0
- package/src/goal-reducer.ts +280 -0
- package/src/goal-store.ts +315 -0
- package/src/goal-types.ts +104 -0
- package/src/goal-verifier.ts +241 -0
- package/src/index.ts +134 -1147
- package/src/loop-reducer.ts +148 -0
- package/src/monitor-completion-coordinator.ts +24 -0
- package/src/monitor-manager.ts +115 -27
- package/src/monitor-reducer.ts +166 -0
- package/src/notification-reducer.ts +155 -0
- package/src/runtime/monitor-ondone-runtime.ts +75 -0
- package/src/runtime/notification-runtime.ts +212 -0
- package/src/runtime/scope.ts +37 -0
- package/src/runtime/session-runtime.ts +168 -0
- package/src/runtime/task-backlog-runtime.ts +163 -0
- package/src/runtime/task-rpc.ts +150 -0
- package/src/store.ts +132 -50
- package/src/task-backlog-coordinator.ts +32 -0
- package/src/task-reducer.ts +152 -0
- package/src/task-store.ts +103 -31
- package/src/tools/loop-tools.ts +304 -0
- package/src/tools/monitor-tools.ts +145 -0
- package/src/tools/native-task-tools.ts +144 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createCoordinator, } from "../coordinator.js";
|
|
2
|
+
import { reduceMonitorCompletionEvent, } from "../monitor-completion-coordinator.js";
|
|
3
|
+
export function createMonitorOnDoneRuntime(options) {
|
|
4
|
+
const { monitorManager, getLoop, deleteLoop, onLoopFire, debug } = options;
|
|
5
|
+
const monitorCompletionReducerHandler = (incoming) => {
|
|
6
|
+
if (incoming.type !== "MONITOR_ONDONE_TRIGGERED")
|
|
7
|
+
return [];
|
|
8
|
+
return reduceMonitorCompletionEvent(incoming);
|
|
9
|
+
};
|
|
10
|
+
const monitorCompletionCoordinator = createCoordinator({
|
|
11
|
+
reducers: [monitorCompletionReducerHandler],
|
|
12
|
+
effectHandlers: {
|
|
13
|
+
DELIVER_MONITOR_ONDONE_WAKE: (effect) => {
|
|
14
|
+
const { loopId, monitorId } = effect.payload;
|
|
15
|
+
const current = getLoop(loopId);
|
|
16
|
+
if (!current)
|
|
17
|
+
return;
|
|
18
|
+
debug?.(`onDone loop #${loopId} — monitor #${monitorId} completed, delivering through coordinator`);
|
|
19
|
+
onLoopFire(current);
|
|
20
|
+
deleteLoop(loopId);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
function register(doneLoop, monitorId) {
|
|
25
|
+
const deliver = () => {
|
|
26
|
+
void monitorCompletionCoordinator.dispatch({
|
|
27
|
+
type: "MONITOR_ONDONE_TRIGGERED",
|
|
28
|
+
at: Date.now(),
|
|
29
|
+
source: "monitor",
|
|
30
|
+
entityType: "monitor",
|
|
31
|
+
entityId: monitorId,
|
|
32
|
+
payload: { loopId: doneLoop.id, monitorId },
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
const registered = monitorManager.onComplete(monitorId, deliver);
|
|
36
|
+
if (registered)
|
|
37
|
+
return;
|
|
38
|
+
const monitor = monitorManager.get(monitorId);
|
|
39
|
+
if (monitor && monitor.status !== "running") {
|
|
40
|
+
if (monitor.status === "completed") {
|
|
41
|
+
deliver();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
debug?.(`onDone loop #${doneLoop.id} — monitor #${monitorId} already ${monitor.status}, expiring`);
|
|
45
|
+
deleteLoop(doneLoop.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { register };
|
|
49
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Trigger } from "../types.js";
|
|
3
|
+
export interface LoopFireEvent {
|
|
4
|
+
loopId: string;
|
|
5
|
+
prompt: string;
|
|
6
|
+
trigger: Trigger | string;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
readOnly?: boolean;
|
|
9
|
+
recurring?: boolean;
|
|
10
|
+
autoTask?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface PendingNotification extends LoopFireEvent {
|
|
13
|
+
key: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
export interface NotificationRuntimeOptions {
|
|
17
|
+
pi: ExtensionAPI;
|
|
18
|
+
hasPendingTasks: () => Promise<number>;
|
|
19
|
+
cleanDoneTasks: () => Promise<void>;
|
|
20
|
+
getHasPendingMessages: () => boolean;
|
|
21
|
+
debug?: (...args: unknown[]) => void;
|
|
22
|
+
}
|
|
23
|
+
export interface NotificationRuntime {
|
|
24
|
+
syncRuntimeState(options?: {
|
|
25
|
+
agentRunning?: boolean;
|
|
26
|
+
hasPendingMessages?: boolean;
|
|
27
|
+
}): void;
|
|
28
|
+
queueOrDeliverNotification(data: LoopFireEvent): Promise<void>;
|
|
29
|
+
flushPendingNotifications(options?: {
|
|
30
|
+
ignorePendingMessages?: boolean;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
clear(reason: "session_shutdown" | "session_switch"): void;
|
|
33
|
+
}
|
|
34
|
+
export declare function createNotificationRuntime(options: NotificationRuntimeOptions): NotificationRuntime;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createCoordinator, } from "../coordinator.js";
|
|
2
|
+
import { reduceNotificationState, } from "../notification-reducer.js";
|
|
3
|
+
export function createNotificationRuntime(options) {
|
|
4
|
+
const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
|
|
5
|
+
let notificationState = {
|
|
6
|
+
notificationsByKey: {},
|
|
7
|
+
agentRunning: false,
|
|
8
|
+
hasPendingMessages: false,
|
|
9
|
+
};
|
|
10
|
+
let flushPromise;
|
|
11
|
+
let notificationCoordinatorDelivered = false;
|
|
12
|
+
let notificationCoordinatorDeliveredSuccessfully = false;
|
|
13
|
+
const notificationReducerHandler = (incoming) => {
|
|
14
|
+
const result = reduceNotificationState(notificationState, incoming);
|
|
15
|
+
notificationState = result.state;
|
|
16
|
+
return result.effects;
|
|
17
|
+
};
|
|
18
|
+
const notificationCoordinator = createCoordinator({
|
|
19
|
+
reducers: [notificationReducerHandler],
|
|
20
|
+
effectHandlers: {
|
|
21
|
+
REQUEST_NOTIFICATION_FLUSH: () => { },
|
|
22
|
+
DELIVER_NOTIFICATION: async (effect) => {
|
|
23
|
+
notificationCoordinatorDelivered = true;
|
|
24
|
+
notificationCoordinatorDeliveredSuccessfully = await deliverNotification(effect.payload.notification);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
function applyNotificationEvent(event) {
|
|
29
|
+
const result = reduceNotificationState(notificationState, event);
|
|
30
|
+
notificationState = result.state;
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
function syncRuntimeState(options) {
|
|
34
|
+
applyNotificationEvent({
|
|
35
|
+
type: "NOTIFICATION_RUNTIME_UPDATED",
|
|
36
|
+
at: Date.now(),
|
|
37
|
+
source: "system",
|
|
38
|
+
entityType: "notification",
|
|
39
|
+
payload: {
|
|
40
|
+
agentRunning: options?.agentRunning ?? notificationState.agentRunning,
|
|
41
|
+
hasPendingMessages: options?.hasPendingMessages ?? getHasPendingMessages(),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
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";
|
|
53
|
+
const loopId = data.loopId || "?";
|
|
54
|
+
const prompt = data.prompt || "loop fired";
|
|
55
|
+
const constraint = data.readOnly
|
|
56
|
+
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
57
|
+
: "";
|
|
58
|
+
return [
|
|
59
|
+
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
60
|
+
prompt,
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
|
63
|
+
function buildPendingNotification(data) {
|
|
64
|
+
const key = data.recurring ? `loop:${data.loopId}` : `loop:${data.loopId}:${data.timestamp}`;
|
|
65
|
+
return {
|
|
66
|
+
...data,
|
|
67
|
+
key,
|
|
68
|
+
message: buildLoopFireMessage(data),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function deliverNotification(notification) {
|
|
72
|
+
if (notification.autoTask) {
|
|
73
|
+
const pending = await hasPendingTasks();
|
|
74
|
+
if (pending === 0) {
|
|
75
|
+
debug?.(`loop:fire #${notification.loopId} — no pending tasks at delivery time, dropping wake`);
|
|
76
|
+
await cleanDoneTasks();
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
syncRuntimeState({ agentRunning: true });
|
|
81
|
+
pi.sendMessage({
|
|
82
|
+
customType: "pi-loop",
|
|
83
|
+
content: notification.message,
|
|
84
|
+
display: false,
|
|
85
|
+
details: {
|
|
86
|
+
loopId: notification.loopId,
|
|
87
|
+
trigger: notification.trigger,
|
|
88
|
+
recurring: notification.recurring,
|
|
89
|
+
readOnly: notification.readOnly,
|
|
90
|
+
autoTask: notification.autoTask,
|
|
91
|
+
timestamp: notification.timestamp,
|
|
92
|
+
},
|
|
93
|
+
}, {
|
|
94
|
+
deliverAs: "steer",
|
|
95
|
+
triggerTurn: true,
|
|
96
|
+
});
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async function flushPendingNotifications(options) {
|
|
100
|
+
if (flushPromise)
|
|
101
|
+
return flushPromise;
|
|
102
|
+
flushPromise = (async () => {
|
|
103
|
+
syncRuntimeState({ hasPendingMessages: getHasPendingMessages() });
|
|
104
|
+
while (true) {
|
|
105
|
+
notificationCoordinatorDelivered = false;
|
|
106
|
+
notificationCoordinatorDeliveredSuccessfully = false;
|
|
107
|
+
await notificationCoordinator.dispatch({
|
|
108
|
+
type: "NOTIFICATION_FLUSH_REQUESTED",
|
|
109
|
+
at: Date.now(),
|
|
110
|
+
source: "system",
|
|
111
|
+
entityType: "notification",
|
|
112
|
+
payload: { ignorePendingMessages: options?.ignorePendingMessages },
|
|
113
|
+
});
|
|
114
|
+
if (!notificationCoordinatorDelivered)
|
|
115
|
+
return;
|
|
116
|
+
if (notificationCoordinatorDeliveredSuccessfully)
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
})().finally(() => {
|
|
120
|
+
flushPromise = undefined;
|
|
121
|
+
});
|
|
122
|
+
return flushPromise;
|
|
123
|
+
}
|
|
124
|
+
async function queueOrDeliverNotification(data) {
|
|
125
|
+
const notification = buildPendingNotification(data);
|
|
126
|
+
applyNotificationEvent({
|
|
127
|
+
type: "NOTIFICATION_QUEUED",
|
|
128
|
+
at: notification.timestamp,
|
|
129
|
+
source: "system",
|
|
130
|
+
entityType: "notification",
|
|
131
|
+
entityId: notification.key,
|
|
132
|
+
payload: { notification },
|
|
133
|
+
});
|
|
134
|
+
await flushPendingNotifications();
|
|
135
|
+
}
|
|
136
|
+
function clear(reason) {
|
|
137
|
+
syncRuntimeState({ agentRunning: false, hasPendingMessages: false });
|
|
138
|
+
applyNotificationEvent({
|
|
139
|
+
type: "NOTIFICATION_CLEARED",
|
|
140
|
+
at: Date.now(),
|
|
141
|
+
source: "session",
|
|
142
|
+
entityType: "notification",
|
|
143
|
+
payload: { reason },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
syncRuntimeState,
|
|
148
|
+
queueOrDeliverNotification,
|
|
149
|
+
flushPendingNotifications,
|
|
150
|
+
clear,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type LoopScope = "memory" | "session" | "project";
|
|
2
|
+
export interface ScopeOptions {
|
|
3
|
+
piLoopEnv?: string;
|
|
4
|
+
loopScope: LoopScope;
|
|
5
|
+
cwd?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function resolveLoopStorePath(options: ScopeOptions, sessionId?: string): string | undefined;
|
|
8
|
+
export declare function resolveTaskStorePath(options: ScopeOptions, sessionId?: string): string | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
export function resolveLoopStorePath(options, sessionId) {
|
|
3
|
+
const cwd = options.cwd ?? process.cwd();
|
|
4
|
+
const { piLoopEnv, loopScope } = options;
|
|
5
|
+
if (piLoopEnv === "off")
|
|
6
|
+
return undefined;
|
|
7
|
+
if (piLoopEnv?.startsWith("/"))
|
|
8
|
+
return piLoopEnv;
|
|
9
|
+
if (piLoopEnv?.startsWith("."))
|
|
10
|
+
return resolve(piLoopEnv);
|
|
11
|
+
if (piLoopEnv)
|
|
12
|
+
return piLoopEnv;
|
|
13
|
+
if (loopScope === "memory")
|
|
14
|
+
return undefined;
|
|
15
|
+
if (loopScope === "session" && sessionId) {
|
|
16
|
+
return join(cwd, ".pi", "loops", `loops-${sessionId}.json`);
|
|
17
|
+
}
|
|
18
|
+
if (loopScope === "session")
|
|
19
|
+
return undefined;
|
|
20
|
+
return join(cwd, ".pi", "loops", "loops.json");
|
|
21
|
+
}
|
|
22
|
+
export function resolveTaskStorePath(options, sessionId) {
|
|
23
|
+
const cwd = options.cwd ?? process.cwd();
|
|
24
|
+
const { loopScope } = options;
|
|
25
|
+
if (loopScope === "memory")
|
|
26
|
+
return undefined;
|
|
27
|
+
if (loopScope === "session" && sessionId) {
|
|
28
|
+
return join(cwd, ".pi", "tasks", `tasks-${sessionId}.json`);
|
|
29
|
+
}
|
|
30
|
+
if (loopScope === "session")
|
|
31
|
+
return undefined;
|
|
32
|
+
return join(cwd, ".pi", "tasks", "tasks.json");
|
|
33
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { LoopStore } from "../store.js";
|
|
3
|
+
import type { NotificationRuntime } from "./notification-runtime.js";
|
|
4
|
+
import type { LoopScope } from "./scope.js";
|
|
5
|
+
export interface SessionSwitchEvent {
|
|
6
|
+
reason?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SessionRuntimeOptions {
|
|
9
|
+
pi: ExtensionAPI;
|
|
10
|
+
getLoopScope: () => LoopScope;
|
|
11
|
+
getPiLoopEnv: () => string | undefined;
|
|
12
|
+
recreateSessionStore: (sessionId: string) => void;
|
|
13
|
+
clearAllLoops: () => void;
|
|
14
|
+
getStore: () => LoopStore;
|
|
15
|
+
getScheduler: () => {
|
|
16
|
+
nextFire(id: string): number | undefined;
|
|
17
|
+
pump(now: number, filter?: (entry: {
|
|
18
|
+
id: string;
|
|
19
|
+
}) => boolean): void;
|
|
20
|
+
};
|
|
21
|
+
getTriggerSystem: () => {
|
|
22
|
+
start(): void;
|
|
23
|
+
stop(): void;
|
|
24
|
+
};
|
|
25
|
+
setLatestCtx: (ctx: ExtensionContext) => void;
|
|
26
|
+
setSessionId: (sessionId: string | undefined) => void;
|
|
27
|
+
widget: {
|
|
28
|
+
setUICtx(ui: ExtensionContext["ui"]): void;
|
|
29
|
+
update(): void;
|
|
30
|
+
};
|
|
31
|
+
notificationRuntime: NotificationRuntime;
|
|
32
|
+
flushPendingNotifications: (options?: {
|
|
33
|
+
ignorePendingMessages?: boolean;
|
|
34
|
+
}) => Promise<void>;
|
|
35
|
+
cleanupTaskBacklogLoops: () => Promise<number>;
|
|
36
|
+
hasPendingTasks: () => Promise<number>;
|
|
37
|
+
cleanDoneTasks: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export declare function registerSessionRuntimeHooks(options: SessionRuntimeOptions): void;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export function registerSessionRuntimeHooks(options) {
|
|
2
|
+
const { pi, getLoopScope, getPiLoopEnv, recreateSessionStore, clearAllLoops, getStore, getScheduler, getTriggerSystem, setLatestCtx, setSessionId, widget, notificationRuntime, flushPendingNotifications, cleanupTaskBacklogLoops, hasPendingTasks, cleanDoneTasks, } = options;
|
|
3
|
+
let storeUpgraded = false;
|
|
4
|
+
let persistedShown = false;
|
|
5
|
+
function upgradeStoreIfNeeded(ctx) {
|
|
6
|
+
if (storeUpgraded)
|
|
7
|
+
return;
|
|
8
|
+
if (getLoopScope() === "session" && !getPiLoopEnv()) {
|
|
9
|
+
recreateSessionStore(ctx.sessionManager.getSessionId());
|
|
10
|
+
}
|
|
11
|
+
storeUpgraded = true;
|
|
12
|
+
}
|
|
13
|
+
function showPersistedLoops(_isResume = false) {
|
|
14
|
+
if (persistedShown)
|
|
15
|
+
return;
|
|
16
|
+
persistedShown = true;
|
|
17
|
+
const sessionStartedAt = Date.now();
|
|
18
|
+
const loops = getStore().list();
|
|
19
|
+
if (loops.length > 0) {
|
|
20
|
+
getStore().clearExpired();
|
|
21
|
+
getStore().expireEventLoops(sessionStartedAt);
|
|
22
|
+
getTriggerSystem().start();
|
|
23
|
+
widget.update();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function pumpLoops() {
|
|
27
|
+
const pendingTasks = new Map();
|
|
28
|
+
for (const entry of getStore().list()) {
|
|
29
|
+
if (entry.status !== "active")
|
|
30
|
+
continue;
|
|
31
|
+
if (!entry.autoTask)
|
|
32
|
+
continue;
|
|
33
|
+
if (entry.trigger.type !== "cron" && entry.trigger.type !== "hybrid")
|
|
34
|
+
continue;
|
|
35
|
+
const nextFire = getScheduler().nextFire(entry.id);
|
|
36
|
+
if (!nextFire || Date.now() < nextFire)
|
|
37
|
+
continue;
|
|
38
|
+
const pending = await hasPendingTasks();
|
|
39
|
+
if (pending <= 0)
|
|
40
|
+
pendingTasks.set(entry.id, true);
|
|
41
|
+
}
|
|
42
|
+
getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
43
|
+
}
|
|
44
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
45
|
+
setLatestCtx(ctx);
|
|
46
|
+
setSessionId(ctx.sessionManager.getSessionId());
|
|
47
|
+
widget.setUICtx(ctx.ui);
|
|
48
|
+
upgradeStoreIfNeeded(ctx);
|
|
49
|
+
widget.update();
|
|
50
|
+
await pumpLoops();
|
|
51
|
+
});
|
|
52
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
53
|
+
setLatestCtx(ctx);
|
|
54
|
+
widget.setUICtx(ctx.ui);
|
|
55
|
+
upgradeStoreIfNeeded(ctx);
|
|
56
|
+
showPersistedLoops();
|
|
57
|
+
widget.update();
|
|
58
|
+
});
|
|
59
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
60
|
+
notificationRuntime.syncRuntimeState({
|
|
61
|
+
agentRunning: true,
|
|
62
|
+
hasPendingMessages: ctx.hasPendingMessages(),
|
|
63
|
+
});
|
|
64
|
+
setLatestCtx(ctx);
|
|
65
|
+
widget.setUICtx(ctx.ui);
|
|
66
|
+
});
|
|
67
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
68
|
+
setLatestCtx(ctx);
|
|
69
|
+
widget.setUICtx(ctx.ui);
|
|
70
|
+
notificationRuntime.syncRuntimeState({
|
|
71
|
+
agentRunning: false,
|
|
72
|
+
hasPendingMessages: ctx.hasPendingMessages(),
|
|
73
|
+
});
|
|
74
|
+
await flushPendingNotifications({ ignorePendingMessages: true });
|
|
75
|
+
await cleanupTaskBacklogLoops();
|
|
76
|
+
await pumpLoops();
|
|
77
|
+
});
|
|
78
|
+
pi.on("session_shutdown", async () => {
|
|
79
|
+
notificationRuntime.clear("session_shutdown");
|
|
80
|
+
});
|
|
81
|
+
pi.on("session_switch", async (event, ctx) => {
|
|
82
|
+
setLatestCtx(ctx);
|
|
83
|
+
widget.setUICtx(ctx.ui);
|
|
84
|
+
getTriggerSystem().stop();
|
|
85
|
+
notificationRuntime.clear("session_switch");
|
|
86
|
+
setSessionId(undefined);
|
|
87
|
+
const isResume = event?.reason === "resume";
|
|
88
|
+
storeUpgraded = false;
|
|
89
|
+
persistedShown = false;
|
|
90
|
+
if (!isResume && getLoopScope() === "memory") {
|
|
91
|
+
clearAllLoops();
|
|
92
|
+
}
|
|
93
|
+
upgradeStoreIfNeeded(ctx);
|
|
94
|
+
showPersistedLoops(isResume);
|
|
95
|
+
widget.update();
|
|
96
|
+
});
|
|
97
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
98
|
+
setLatestCtx(ctx);
|
|
99
|
+
widget.setUICtx(ctx.ui);
|
|
100
|
+
const typed = event;
|
|
101
|
+
if (typed.toolName !== "bash" || typed.isError)
|
|
102
|
+
return;
|
|
103
|
+
const command = typed.args?.command ?? typed.input?.command;
|
|
104
|
+
if (typeof command !== "string")
|
|
105
|
+
return;
|
|
106
|
+
if (!/^\s*git\s+commit\b/i.test(command))
|
|
107
|
+
return;
|
|
108
|
+
await cleanDoneTasks();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { TaskStore } from "../task-store.js";
|
|
2
|
+
import type { LoopEntry, Trigger } from "../types.js";
|
|
3
|
+
export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
4
|
+
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
|
+
export interface TaskBacklogRuntimeOptions {
|
|
6
|
+
getLoops: () => LoopEntry[];
|
|
7
|
+
createLoop: (trigger: Trigger, prompt: string, options: {
|
|
8
|
+
recurring: boolean;
|
|
9
|
+
taskBacklog?: boolean;
|
|
10
|
+
maxFires?: number;
|
|
11
|
+
}) => LoopEntry;
|
|
12
|
+
deleteLoop: (id: string) => void;
|
|
13
|
+
addTrigger: (entry: LoopEntry) => void;
|
|
14
|
+
removeTrigger: (id: string) => void;
|
|
15
|
+
updateWidget: () => void;
|
|
16
|
+
hasPendingTasks: () => Promise<number>;
|
|
17
|
+
bootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
|
|
18
|
+
triggerHasEventSource: (trigger: Trigger | string, source: string) => boolean;
|
|
19
|
+
debug?: (...args: unknown[]) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface TaskBacklogRuntime {
|
|
22
|
+
cleanupTaskBacklogLoops(): Promise<number>;
|
|
23
|
+
ensureAutoTaskWorkerLoop(taskStore: TaskStore): Promise<{
|
|
24
|
+
entry?: LoopEntry;
|
|
25
|
+
created: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
evaluateTaskBacklog(taskStore?: TaskStore, pendingCount?: number): Promise<{
|
|
28
|
+
entry?: LoopEntry;
|
|
29
|
+
created: boolean;
|
|
30
|
+
cleaned: number;
|
|
31
|
+
}>;
|
|
32
|
+
isAutoTaskWorkerLoop(entry: LoopEntry): boolean;
|
|
33
|
+
isTaskBacklogLoop(entry: LoopEntry): boolean;
|
|
34
|
+
findAutoTaskWorkerLoop(): LoopEntry | undefined;
|
|
35
|
+
}
|
|
36
|
+
export declare function createTaskBacklogRuntime(options: TaskBacklogRuntimeOptions): TaskBacklogRuntime;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createCoordinator, } from "../coordinator.js";
|
|
2
|
+
import { reduceTaskBacklogEvent, } from "../task-backlog-coordinator.js";
|
|
3
|
+
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
4
|
+
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
|
+
export function createTaskBacklogRuntime(options) {
|
|
6
|
+
const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, debug, } = options;
|
|
7
|
+
function isAutoTaskWorkerLoop(entry) {
|
|
8
|
+
return entry.status === "active"
|
|
9
|
+
&& entry.prompt === AUTO_TASK_WORKER_PROMPT
|
|
10
|
+
&& triggerHasEventSource(entry.trigger, "tasks:created");
|
|
11
|
+
}
|
|
12
|
+
function isTaskBacklogLoop(entry) {
|
|
13
|
+
return entry.status === "active"
|
|
14
|
+
&& triggerHasEventSource(entry.trigger, "tasks:created")
|
|
15
|
+
&& (entry.taskBacklog === true || isAutoTaskWorkerLoop(entry));
|
|
16
|
+
}
|
|
17
|
+
function findAutoTaskWorkerLoop() {
|
|
18
|
+
return getLoops().find(isAutoTaskWorkerLoop);
|
|
19
|
+
}
|
|
20
|
+
async function cleanupTaskBacklogLoops() {
|
|
21
|
+
const backlogLoops = getLoops().filter(isTaskBacklogLoop);
|
|
22
|
+
if (backlogLoops.length === 0)
|
|
23
|
+
return 0;
|
|
24
|
+
const pending = await hasPendingTasks();
|
|
25
|
+
if (pending < 0 || pending > 0)
|
|
26
|
+
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
|
+
}
|
|
32
|
+
updateWidget();
|
|
33
|
+
return backlogLoops.length;
|
|
34
|
+
}
|
|
35
|
+
async function ensureAutoTaskWorkerLoop(taskStore) {
|
|
36
|
+
if (taskStore.pendingCount() < AUTO_TASK_WORKER_THRESHOLD)
|
|
37
|
+
return { created: false };
|
|
38
|
+
const existing = findAutoTaskWorkerLoop();
|
|
39
|
+
if (existing)
|
|
40
|
+
return { entry: existing, created: false };
|
|
41
|
+
const trigger = {
|
|
42
|
+
type: "hybrid",
|
|
43
|
+
cron: "*/5 * * * *",
|
|
44
|
+
event: { source: "tasks:created" },
|
|
45
|
+
debounceMs: 30000,
|
|
46
|
+
};
|
|
47
|
+
const entry = createLoop(trigger, AUTO_TASK_WORKER_PROMPT, {
|
|
48
|
+
recurring: true,
|
|
49
|
+
taskBacklog: true,
|
|
50
|
+
maxFires: 30,
|
|
51
|
+
});
|
|
52
|
+
addTrigger(entry);
|
|
53
|
+
await bootstrapTaskLoop(entry);
|
|
54
|
+
updateWidget();
|
|
55
|
+
return { entry, created: true };
|
|
56
|
+
}
|
|
57
|
+
let taskBacklogCoordinatorStore;
|
|
58
|
+
let taskBacklogCoordinatorWorker = { created: false };
|
|
59
|
+
let taskBacklogCoordinatorCleanupCount = 0;
|
|
60
|
+
const taskBacklogReducerHandler = (incoming) => {
|
|
61
|
+
if (incoming.type !== "TASK_BACKLOG_EVALUATED")
|
|
62
|
+
return [];
|
|
63
|
+
return reduceTaskBacklogEvent(incoming);
|
|
64
|
+
};
|
|
65
|
+
const taskBacklogCoordinator = createCoordinator({
|
|
66
|
+
reducers: [taskBacklogReducerHandler],
|
|
67
|
+
effectHandlers: {
|
|
68
|
+
ENSURE_AUTO_TASK_WORKER: async () => {
|
|
69
|
+
if (!taskBacklogCoordinatorStore)
|
|
70
|
+
return;
|
|
71
|
+
taskBacklogCoordinatorWorker = await ensureAutoTaskWorkerLoop(taskBacklogCoordinatorStore);
|
|
72
|
+
},
|
|
73
|
+
CLEANUP_TASK_BACKLOG_LOOPS: async () => {
|
|
74
|
+
taskBacklogCoordinatorCleanupCount = await cleanupTaskBacklogLoops();
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
async function evaluateTaskBacklog(taskStore, pendingCount) {
|
|
79
|
+
const resolvedPending = pendingCount ?? (taskStore ? taskStore.pendingCount() : await hasPendingTasks());
|
|
80
|
+
taskBacklogCoordinatorStore = taskStore;
|
|
81
|
+
taskBacklogCoordinatorWorker = { created: false };
|
|
82
|
+
taskBacklogCoordinatorCleanupCount = 0;
|
|
83
|
+
await taskBacklogCoordinator.dispatch({
|
|
84
|
+
type: "TASK_BACKLOG_EVALUATED",
|
|
85
|
+
at: Date.now(),
|
|
86
|
+
source: "system",
|
|
87
|
+
entityType: "task",
|
|
88
|
+
payload: { pendingCount: resolvedPending, threshold: AUTO_TASK_WORKER_THRESHOLD },
|
|
89
|
+
});
|
|
90
|
+
taskBacklogCoordinatorStore = undefined;
|
|
91
|
+
return {
|
|
92
|
+
entry: taskBacklogCoordinatorWorker.entry,
|
|
93
|
+
created: taskBacklogCoordinatorWorker.created,
|
|
94
|
+
cleaned: taskBacklogCoordinatorCleanupCount,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
cleanupTaskBacklogLoops,
|
|
99
|
+
ensureAutoTaskWorkerLoop,
|
|
100
|
+
evaluateTaskBacklog,
|
|
101
|
+
isAutoTaskWorkerLoop,
|
|
102
|
+
isTaskBacklogLoop,
|
|
103
|
+
findAutoTaskWorkerLoop,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TaskStore } from "../task-store.js";
|
|
3
|
+
import type { LoopEntry } from "../types.js";
|
|
4
|
+
export interface TaskRuntimeBridgeOptions {
|
|
5
|
+
pi: ExtensionAPI;
|
|
6
|
+
isTasksAvailable: () => boolean;
|
|
7
|
+
setTasksAvailable: (available: boolean) => void;
|
|
8
|
+
getNativeTaskStore: () => TaskStore | undefined;
|
|
9
|
+
onNativeTaskCreated?: (taskStore: TaskStore) => void;
|
|
10
|
+
onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
|
|
11
|
+
debug?: (...args: unknown[]) => void;
|
|
12
|
+
}
|
|
13
|
+
export interface TaskRuntimeBridge {
|
|
14
|
+
checkTasksVersion(): void;
|
|
15
|
+
autoCreateTask(entry: LoopEntry): Promise<string | undefined>;
|
|
16
|
+
hasPendingTasks(): Promise<number>;
|
|
17
|
+
cleanDoneTasks(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare function createTaskRuntimeBridge(options: TaskRuntimeBridgeOptions): TaskRuntimeBridge;
|