@trevonistrevon/pi-loop 0.6.2 → 0.6.4
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/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +14 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +4 -1
- package/dist/runtime/notification-runtime.d.ts +4 -1
- package/dist/runtime/notification-runtime.js +27 -1
- package/dist/runtime/session-runtime.js +14 -3
- package/dist/runtime/task-backlog-runtime.d.ts +1 -1
- package/dist/runtime/task-backlog-runtime.js +1 -1
- package/dist/runtime/task-events.d.ts +2 -1
- package/dist/runtime/task-events.js +1 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +62 -1
- package/dist/store.d.ts +10 -1
- package/dist/store.js +51 -0
- package/dist/task-reducer.d.ts +2 -1
- package/dist/task-reducer.js +1 -0
- package/dist/task-store.d.ts +2 -2
- package/dist/task-store.js +2 -2
- package/dist/task-types.d.ts +6 -0
- package/dist/tools/loop-tools.d.ts +17 -1
- package/dist/tools/loop-tools.js +331 -26
- package/dist/tools/monitor-tools.js +43 -9
- package/dist/tools/native-task-tools.js +56 -11
- package/dist/tools/tool-result.d.ts +12 -2
- package/dist/tools/tool-result.js +7 -3
- package/dist/types.d.ts +35 -0
- package/dist/ui/tool-renderer.d.ts +7 -0
- package/dist/ui/tool-renderer.js +34 -0
- package/dist/ui/widget.js +4 -4
- package/dist/workflow-reducer.d.ts +18 -0
- package/dist/workflow-reducer.js +82 -0
- package/docs/USAGE_GUIDE.md +41 -0
- package/package.json +3 -3
- package/src/api.ts +9 -1
- package/src/index.ts +14 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +4 -1
- package/src/runtime/notification-runtime.ts +34 -2
- package/src/runtime/session-runtime.ts +15 -3
- package/src/runtime/task-backlog-runtime.ts +1 -1
- package/src/runtime/task-events.ts +3 -1
- package/src/runtime/task-rpc.ts +66 -0
- package/src/store.ts +51 -2
- package/src/task-reducer.ts +3 -1
- package/src/task-store.ts +3 -3
- package/src/task-types.ts +7 -0
- package/src/tools/loop-tools.ts +376 -20
- package/src/tools/monitor-tools.ts +44 -7
- package/src/tools/native-task-tools.ts +56 -8
- package/src/tools/tool-result.ts +18 -2
- package/src/types.ts +41 -0
- package/src/ui/tool-renderer.ts +45 -0
- package/src/ui/widget.ts +4 -4
- package/src/workflow-reducer.ts +107 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.4](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.3...pi-loop-v0.6.4) (2026-07-22)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **ui:** render compact status cards ([02925ae](https://github.com/trvon/pi-loop/commit/02925ae2af1404fd86f8daab4f5ecef3782550e3))
|
|
9
|
+
* **workflow:** add state loop foundation ([2fddbe9](https://github.com/trvon/pi-loop/commit/2fddbe904cf3b413618572b8488e8aa9357590cd))
|
|
10
|
+
* **workflow:** add task-driven state loops ([f174211](https://github.com/trvon/pi-loop/commit/f1742117a66c9700b08427aa6528234546f5b9cc))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **deps:** raise Pi security baseline ([5f538cf](https://github.com/trvon/pi-loop/commit/5f538cfb44cbb5e254ec87b5cd35f6ece1535fda))
|
|
16
|
+
|
|
17
|
+
## [0.6.3](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.2...pi-loop-v0.6.3) (2026-07-17)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Bug Fixes
|
|
21
|
+
|
|
22
|
+
* **loop:** prevent premature self-deletion ([6049e15](https://github.com/trvon/pi-loop/commit/6049e1599d9c6c1ab99a9eada72ec75307f25ca1))
|
|
23
|
+
* **ui:** restore loop status after resets ([da580f0](https://github.com/trvon/pi-loop/commit/da580f0d2f5ac414bc3cd884e27b757decd44b5d))
|
|
24
|
+
|
|
3
25
|
## [0.6.2](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.1...pi-loop-v0.6.2) (2026-07-17)
|
|
4
26
|
|
|
5
27
|
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ MonitorStop monitorId="1"
|
|
|
38
38
|
|
|
39
39
|
## What it provides
|
|
40
40
|
|
|
41
|
-
- Cron, event, hybrid,
|
|
41
|
+
- Cron, event, hybrid, dynamic goal, and opt-in workflow loops
|
|
42
42
|
- Idle-safe agent re-wakes with dynamic-loop restart/session-switch recovery
|
|
43
43
|
- Background command monitoring with buffered output and `onDone` wakes
|
|
44
44
|
- Optional `pi-tasks` integration and a native task fallback
|
|
@@ -50,7 +50,8 @@ MonitorStop monitorId="1"
|
|
|
50
50
|
|---|---|
|
|
51
51
|
| `/loop` | Create or manage scheduled, event, and dynamic goal loops |
|
|
52
52
|
| `/tasks` | Manage native fallback tasks when `pi-tasks` is absent |
|
|
53
|
-
| `LoopCreate`, `LoopList`, `LoopUpdate`, `LoopDelete` | Create and control loops |
|
|
53
|
+
| `LoopCreate`, `LoopList`, `LoopUpdate`, `LoopDelete` | Create and control ordinary loops |
|
|
54
|
+
| `WorkflowCreate`, `WorkflowList`, `WorkflowTransition` | Create and advance opt-in task-driven workflows |
|
|
54
55
|
| `MonitorCreate`, `MonitorList`, `MonitorStop` | Run and inspect background commands |
|
|
55
56
|
| `TaskCreate`, `TaskList`, `TaskUpdate`, `TaskDelete` | Native fallback task management |
|
|
56
57
|
|
package/dist/api.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export { type HandleRpcOptions, handleRpc, PROTOCOL_VERSION, RpcError, type RpcE
|
|
|
8
8
|
export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
|
|
9
9
|
export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
|
|
10
10
|
export { TaskStore } from "./task-store.js";
|
|
11
|
-
export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
|
|
11
|
+
export type { TaskEntry, TaskStatus, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
|
|
12
|
+
export type { WorkflowDefinition, WorkflowRunState, WorkflowStateDefinition, WorkflowTaskDefinition, WorkflowTerminalStatus, WorkflowTransitionRecord, } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -116,6 +116,10 @@ export default function (pi) {
|
|
|
116
116
|
onDetectionSettled: () => {
|
|
117
117
|
tasksDetectionSettled = true;
|
|
118
118
|
},
|
|
119
|
+
isDetectionSettled: () => tasksDetectionSettled,
|
|
120
|
+
onNativeTaskCompleted: () => {
|
|
121
|
+
widget.update();
|
|
122
|
+
},
|
|
119
123
|
debug,
|
|
120
124
|
});
|
|
121
125
|
// The RPC server registers at init (not behind the 6s tool-fallback timer) so
|
|
@@ -181,7 +185,9 @@ export default function (pi) {
|
|
|
181
185
|
timestamp: Date.now(),
|
|
182
186
|
readOnly: entry.readOnly,
|
|
183
187
|
recurring: false,
|
|
188
|
+
persistent: entry.recurring,
|
|
184
189
|
autoTask: true,
|
|
190
|
+
taskBacklog: entry.taskBacklog,
|
|
185
191
|
});
|
|
186
192
|
return true;
|
|
187
193
|
}
|
|
@@ -250,8 +256,11 @@ export default function (pi) {
|
|
|
250
256
|
timestamp: firedAt,
|
|
251
257
|
readOnly: firedEntry.readOnly,
|
|
252
258
|
recurring: firedEntry.recurring,
|
|
259
|
+
persistent: firedEntry.recurring,
|
|
253
260
|
autoTask: firedEntry.autoTask,
|
|
261
|
+
taskBacklog: firedEntry.taskBacklog,
|
|
254
262
|
dynamic: firedEntry.dynamic,
|
|
263
|
+
workflow: firedEntry.workflow,
|
|
255
264
|
});
|
|
256
265
|
}
|
|
257
266
|
// ── Session lifecycle ──
|
|
@@ -311,6 +320,11 @@ export default function (pi) {
|
|
|
311
320
|
},
|
|
312
321
|
maybeBootstrapTaskLoop,
|
|
313
322
|
isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
|
|
323
|
+
onDynamicLoopActivated: (entry) => {
|
|
324
|
+
onLoopFire(entry);
|
|
325
|
+
},
|
|
326
|
+
createWorkflowTask: (entry) => taskRuntime.createWorkflowTask(entry),
|
|
327
|
+
completeWorkflowTask: (taskId) => taskRuntime.completeWorkflowTask(taskId),
|
|
314
328
|
});
|
|
315
329
|
function handleMonitorDoneLoop(doneLoop, monitorId) {
|
|
316
330
|
monitorOnDoneRuntime.register(doneLoop, monitorId);
|
package/dist/loop-reducer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, LoopEntry, Trigger, WorkflowDefinition } from "./types.js";
|
|
2
2
|
export declare const MAX_LOOP_EXPIRY_MS: number;
|
|
3
3
|
/**
|
|
4
4
|
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
@@ -27,6 +27,7 @@ export type LoopReducerEvent = {
|
|
|
27
27
|
readOnly?: boolean;
|
|
28
28
|
maxFires?: number;
|
|
29
29
|
dynamic?: Partial<DynamicLoopState>;
|
|
30
|
+
workflow?: WorkflowDefinition;
|
|
30
31
|
};
|
|
31
32
|
} | {
|
|
32
33
|
type: "LOOP_PAUSED" | "LOOP_RESUMED" | "LOOP_FIRED" | "LOOP_DELETED" | "LOOP_MAX_FIRES_REACHED" | "LOOP_BACKLOG_EMPTY";
|
|
@@ -58,6 +59,28 @@ export type LoopReducerEvent = {
|
|
|
58
59
|
prompt?: string;
|
|
59
60
|
dynamic: Partial<DynamicLoopState>;
|
|
60
61
|
};
|
|
62
|
+
} | {
|
|
63
|
+
type: "LOOP_WORKFLOW_TRANSITION";
|
|
64
|
+
at: number;
|
|
65
|
+
source: ReducerSource;
|
|
66
|
+
entityType?: "loop";
|
|
67
|
+
entityId?: string;
|
|
68
|
+
payload: {
|
|
69
|
+
id: string;
|
|
70
|
+
outcome: string;
|
|
71
|
+
evidence?: string;
|
|
72
|
+
activeTaskId?: string;
|
|
73
|
+
};
|
|
74
|
+
} | {
|
|
75
|
+
type: "LOOP_WORKFLOW_TASK_SET";
|
|
76
|
+
at: number;
|
|
77
|
+
source: ReducerSource;
|
|
78
|
+
entityType?: "loop";
|
|
79
|
+
entityId?: string;
|
|
80
|
+
payload: {
|
|
81
|
+
id: string;
|
|
82
|
+
taskId?: string;
|
|
83
|
+
};
|
|
61
84
|
};
|
|
62
85
|
export type LoopReducerEffect = {
|
|
63
86
|
type: "PERSIST_LOOP";
|
package/dist/loop-reducer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createWorkflowRun, transitionWorkflowRun } from "./workflow-reducer.js";
|
|
1
2
|
export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2
3
|
/**
|
|
3
4
|
* Whether a loop has reached its fire cap. Single source of truth for the
|
|
@@ -44,6 +45,7 @@ export function reduceLoopState(state, event) {
|
|
|
44
45
|
lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
|
|
45
46
|
}
|
|
46
47
|
: undefined,
|
|
48
|
+
workflow: event.payload.workflow ? createWorkflowRun(event.payload.workflow, event.at) : undefined,
|
|
47
49
|
};
|
|
48
50
|
next.loopsById[id] = loop;
|
|
49
51
|
return {
|
|
@@ -94,6 +96,35 @@ export function reduceLoopState(state, event) {
|
|
|
94
96
|
};
|
|
95
97
|
loop.updatedAt = event.at;
|
|
96
98
|
}
|
|
99
|
+
if (event.type === "LOOP_WORKFLOW_TRANSITION") {
|
|
100
|
+
if (!loop.workflow)
|
|
101
|
+
return { state, effects: [] };
|
|
102
|
+
const result = transitionWorkflowRun(loop.workflow, {
|
|
103
|
+
outcome: event.payload.outcome,
|
|
104
|
+
evidence: event.payload.evidence,
|
|
105
|
+
activeTaskId: event.payload.activeTaskId,
|
|
106
|
+
}, event.at);
|
|
107
|
+
if (!result.applied)
|
|
108
|
+
return { state, effects: [] };
|
|
109
|
+
loop.workflow = result.run;
|
|
110
|
+
loop.dynamic = {
|
|
111
|
+
goal: loop.dynamic?.goal ?? loop.prompt,
|
|
112
|
+
state: result.run.currentState,
|
|
113
|
+
metrics: loop.dynamic?.metrics,
|
|
114
|
+
doneCriteria: loop.dynamic?.doneCriteria,
|
|
115
|
+
iteration: (loop.dynamic?.iteration ?? 0) + 1,
|
|
116
|
+
nextWakeAt: undefined,
|
|
117
|
+
awaitingUpdate: false,
|
|
118
|
+
lastUpdatedAt: event.at,
|
|
119
|
+
};
|
|
120
|
+
loop.updatedAt = event.at;
|
|
121
|
+
}
|
|
122
|
+
if (event.type === "LOOP_WORKFLOW_TASK_SET") {
|
|
123
|
+
if (!loop.workflow)
|
|
124
|
+
return { state, effects: [] };
|
|
125
|
+
loop.workflow = { ...loop.workflow, activeTaskId: event.payload.taskId };
|
|
126
|
+
loop.updatedAt = event.at;
|
|
127
|
+
}
|
|
97
128
|
next.loopsById[id] = loop;
|
|
98
129
|
return {
|
|
99
130
|
state: next,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DynamicLoopState } from "./types.js";
|
|
1
|
+
import type { DynamicLoopState, WorkflowRunState } from "./types.js";
|
|
2
2
|
type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
|
|
3
3
|
export interface ReducerNotification {
|
|
4
4
|
key: string;
|
|
@@ -7,9 +7,12 @@ export interface ReducerNotification {
|
|
|
7
7
|
timestamp: number;
|
|
8
8
|
trigger: unknown;
|
|
9
9
|
recurring?: boolean;
|
|
10
|
+
persistent?: boolean;
|
|
10
11
|
autoTask?: boolean;
|
|
12
|
+
taskBacklog?: boolean;
|
|
11
13
|
readOnly?: boolean;
|
|
12
14
|
dynamic?: DynamicLoopState;
|
|
15
|
+
workflow?: WorkflowRunState;
|
|
13
16
|
}
|
|
14
17
|
export interface NotificationReducerState {
|
|
15
18
|
notificationsByKey: Record<string, ReducerNotification>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { DynamicLoopState, Trigger } from "../types.js";
|
|
2
|
+
import type { DynamicLoopState, Trigger, WorkflowRunState } from "../types.js";
|
|
3
3
|
export interface LoopFireEvent {
|
|
4
4
|
loopId: string;
|
|
5
5
|
prompt: string;
|
|
@@ -7,8 +7,11 @@ export interface LoopFireEvent {
|
|
|
7
7
|
timestamp: number;
|
|
8
8
|
readOnly?: boolean;
|
|
9
9
|
recurring?: boolean;
|
|
10
|
+
persistent?: boolean;
|
|
10
11
|
autoTask?: boolean;
|
|
12
|
+
taskBacklog?: boolean;
|
|
11
13
|
dynamic?: DynamicLoopState;
|
|
14
|
+
workflow?: WorkflowRunState;
|
|
12
15
|
}
|
|
13
16
|
export interface PendingNotification extends LoopFireEvent {
|
|
14
17
|
key: string;
|
|
@@ -50,6 +50,23 @@ export function createNotificationRuntime(options) {
|
|
|
50
50
|
const constraint = data.readOnly
|
|
51
51
|
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
52
52
|
: "";
|
|
53
|
+
if (data.workflow) {
|
|
54
|
+
const state = data.workflow.definition.states[data.workflow.currentState];
|
|
55
|
+
const outcomes = Object.keys(state?.on ?? {});
|
|
56
|
+
const lines = [
|
|
57
|
+
`[pi-loop] Loop #${loopId} fired (workflow).${constraint}`,
|
|
58
|
+
`Goal: ${data.prompt || data.workflow.definition.initialState}`,
|
|
59
|
+
`State: ${data.workflow.currentState}`,
|
|
60
|
+
];
|
|
61
|
+
if (state?.prompt)
|
|
62
|
+
lines.push(`State instructions: ${state.prompt}`);
|
|
63
|
+
if (data.workflow.activeTaskId)
|
|
64
|
+
lines.push(`Active task: #${data.workflow.activeTaskId}`);
|
|
65
|
+
if (outcomes.length > 0)
|
|
66
|
+
lines.push(`Allowed outcomes: ${outcomes.join(", ")}`);
|
|
67
|
+
lines.push(`Workflow lifecycle: Loop #${loopId} is an opt-in state controller. Do not call LoopDelete after this state.`, "Before ending this turn, call WorkflowTransition exactly once with this workflow id and one allowed outcome. Include evidence for the branch decision. Terminal outcomes complete or pause the workflow automatically.");
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
53
70
|
if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
|
|
54
71
|
const dynamic = data.dynamic;
|
|
55
72
|
const lines = [
|
|
@@ -63,12 +80,18 @@ export function createNotificationRuntime(options) {
|
|
|
63
80
|
lines.push(`Metrics: ${dynamic.metrics}`);
|
|
64
81
|
if (dynamic?.doneCriteria)
|
|
65
82
|
lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
66
|
-
lines.push(
|
|
83
|
+
lines.push(`Loop lifecycle: Loop #${loopId} is the persistent controller for the overall goal. Do not call LoopDelete after this iteration.`, "Before ending this turn, call LoopUpdate exactly once: use status=\"completed\" only when the overall goal and done criteria are satisfied; use status=\"continue\" when any work remains, with state/metrics and optional nextInterval; use status=\"paused\" only when genuinely blocked. Omit nextInterval for an idle-driven rewake.");
|
|
67
84
|
return lines.join("\n");
|
|
68
85
|
}
|
|
86
|
+
const lifecycle = data.taskBacklog
|
|
87
|
+
? `Backlog lifecycle: Loop #${loopId} is managed automatically. Do not call LoopDelete; when no pending tasks remain, report that and end this iteration.`
|
|
88
|
+
: (data.persistent ?? data.recurring)
|
|
89
|
+
? `Loop lifecycle: Loop #${loopId} is recurring and remains active after this iteration. Do not call LoopDelete or pause it merely because this run finished, found no changes, or has no immediate work. Stop it only when the user or the loop prompt explicitly requires cancellation.`
|
|
90
|
+
: `Loop lifecycle: Loop #${loopId} is a one-shot wake and cleanup is automatic. Do not call LoopDelete.`;
|
|
69
91
|
return [
|
|
70
92
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
71
93
|
prompt,
|
|
94
|
+
lifecycle,
|
|
72
95
|
].join("\n");
|
|
73
96
|
}
|
|
74
97
|
function buildPendingNotification(data) {
|
|
@@ -97,9 +120,12 @@ export function createNotificationRuntime(options) {
|
|
|
97
120
|
loopId: notification.loopId,
|
|
98
121
|
trigger: notification.trigger,
|
|
99
122
|
recurring: notification.recurring,
|
|
123
|
+
persistent: notification.persistent,
|
|
100
124
|
readOnly: notification.readOnly,
|
|
101
125
|
autoTask: notification.autoTask,
|
|
126
|
+
taskBacklog: notification.taskBacklog,
|
|
102
127
|
dynamic: notification.dynamic,
|
|
128
|
+
workflow: notification.workflow,
|
|
103
129
|
timestamp: notification.timestamp,
|
|
104
130
|
},
|
|
105
131
|
}, {
|
|
@@ -15,8 +15,11 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
15
15
|
return;
|
|
16
16
|
heartbeatTimer = setInterval(() => {
|
|
17
17
|
// Swallow pump failures so a transient error never surfaces as an
|
|
18
|
-
// unhandled rejection;
|
|
19
|
-
void pumpLoops()
|
|
18
|
+
// unhandled rejection; repaint still runs so cleared harness UI heals.
|
|
19
|
+
void pumpLoops()
|
|
20
|
+
.catch(() => { })
|
|
21
|
+
.then(() => widget.update())
|
|
22
|
+
.catch(() => { });
|
|
20
23
|
}, HEARTBEAT_MS);
|
|
21
24
|
heartbeatTimer.unref?.();
|
|
22
25
|
}
|
|
@@ -45,7 +48,6 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
45
48
|
getStore().expireEventLoops(sessionStartedAt);
|
|
46
49
|
getTriggerSystem().start();
|
|
47
50
|
ensureHeartbeat();
|
|
48
|
-
widget.update();
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
async function pumpLoops() {
|
|
@@ -66,6 +68,15 @@ export function registerSessionRuntimeHooks(options) {
|
|
|
66
68
|
}
|
|
67
69
|
getScheduler().pump(Date.now(), (entry) => !pendingTasks.has(entry.id));
|
|
68
70
|
}
|
|
71
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
72
|
+
setLatestCtx(ctx);
|
|
73
|
+
setSessionId(ctx.sessionManager.getSessionId());
|
|
74
|
+
widget.setUICtx(ctx.ui);
|
|
75
|
+
upgradeStoreIfNeeded(ctx);
|
|
76
|
+
ensureHeartbeat();
|
|
77
|
+
showPersistedLoops();
|
|
78
|
+
widget.update();
|
|
79
|
+
});
|
|
69
80
|
pi.on("turn_start", async (_event, ctx) => {
|
|
70
81
|
setLatestCtx(ctx);
|
|
71
82
|
setSessionId(ctx.sessionManager.getSessionId());
|
|
@@ -2,7 +2,7 @@ import type { TaskStore } from "../task-store.js";
|
|
|
2
2
|
import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
|
|
3
3
|
import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
|
|
4
4
|
export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
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,
|
|
5
|
+
export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
6
6
|
export interface TaskBacklogRuntimeOptions {
|
|
7
7
|
getLoops: () => LoopEntry[];
|
|
8
8
|
createLoop: (trigger: Trigger, prompt: string, options: {
|
|
@@ -2,7 +2,7 @@ import { createCoordinator, } from "../coordinator.js";
|
|
|
2
2
|
import { reduceTaskBacklogEvent, } from "../task-backlog-coordinator.js";
|
|
3
3
|
import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./loop-events.js";
|
|
4
4
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
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,
|
|
5
|
+
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, and complete it. If no pending tasks remain, report that and end this iteration; pi-loop manages the worker lifecycle automatically.";
|
|
6
6
|
export function createTaskBacklogRuntime(options) {
|
|
7
7
|
const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
8
8
|
function isAutoTaskWorkerLoop(entry) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { TaskEntry, TaskStatus } from "../task-types.js";
|
|
2
|
+
import type { TaskEntry, TaskStatus, TaskWorkflowLink } from "../task-types.js";
|
|
3
3
|
export type NativeTaskEventName = "tasks:created" | "tasks:started" | "tasks:completed" | "tasks:reopened" | "tasks:updated" | "tasks:deleted";
|
|
4
4
|
export interface NativeTaskEventPayload {
|
|
5
5
|
taskId: string;
|
|
@@ -11,5 +11,6 @@ export interface NativeTaskEventPayload {
|
|
|
11
11
|
updatedAt: number;
|
|
12
12
|
completedAt?: number;
|
|
13
13
|
metadata?: Record<string, unknown>;
|
|
14
|
+
workflow?: TaskWorkflowLink;
|
|
14
15
|
}
|
|
15
16
|
export declare function emitNativeTaskEvent(pi: ExtensionAPI, name: NativeTaskEventName, entry: TaskEntry, previousStatus?: TaskStatus): void;
|
|
@@ -7,7 +7,9 @@ export interface TaskRuntimeBridgeOptions {
|
|
|
7
7
|
setTasksAvailable: (available: boolean) => void;
|
|
8
8
|
getNativeTaskStore: () => TaskStore | undefined;
|
|
9
9
|
onNativeTaskCreated?: (taskStore: TaskStore) => void;
|
|
10
|
+
onNativeTaskCompleted?: (taskStore: TaskStore) => Promise<void> | void;
|
|
10
11
|
onNativeTasksPruned?: (taskStore: TaskStore) => Promise<void> | void;
|
|
12
|
+
isDetectionSettled?: () => boolean;
|
|
11
13
|
/** Called when a detection window opens. */
|
|
12
14
|
onDetectionStarted?: () => void;
|
|
13
15
|
/** Called when a detection window closes (provider found or probe timed out). */
|
|
@@ -17,6 +19,8 @@ export interface TaskRuntimeBridgeOptions {
|
|
|
17
19
|
export interface TaskRuntimeBridge {
|
|
18
20
|
checkTasksVersion(): void;
|
|
19
21
|
autoCreateTask(entry: LoopEntry): Promise<string | undefined>;
|
|
22
|
+
createWorkflowTask(entry: LoopEntry): Promise<string | undefined>;
|
|
23
|
+
completeWorkflowTask(taskId: string): Promise<boolean>;
|
|
20
24
|
hasPendingTasks(): Promise<number>;
|
|
21
25
|
cleanDoneTasks(): Promise<void>;
|
|
22
26
|
}
|
package/dist/runtime/task-rpc.js
CHANGED
|
@@ -4,7 +4,7 @@ import { rpcCall } from "../rpc/cross-extension-rpc.js";
|
|
|
4
4
|
import { NATIVE_TASKS_PROVIDER } from "./native-task-rpc.js";
|
|
5
5
|
import { emitNativeTaskEvent } from "./task-events.js";
|
|
6
6
|
export function createTaskRuntimeBridge(options) {
|
|
7
|
-
const { pi, isTasksAvailable, setTasksAvailable, getNativeTaskStore, onNativeTaskCreated, onNativeTasksPruned, onDetectionStarted, onDetectionSettled, debug, } = options;
|
|
7
|
+
const { pi, isTasksAvailable, setTasksAvailable, getNativeTaskStore, onNativeTaskCreated, onNativeTaskCompleted, onNativeTasksPruned, isDetectionSettled, onDetectionStarted, onDetectionSettled, debug, } = options;
|
|
8
8
|
let detectionEpoch = 0;
|
|
9
9
|
function checkTasksVersion() {
|
|
10
10
|
// Not rpcProbe: pi-loop's own native server also answers this ping, and a
|
|
@@ -62,6 +62,65 @@ export function createTaskRuntimeBridge(options) {
|
|
|
62
62
|
onNativeTaskCreated?.(nativeTaskStore);
|
|
63
63
|
return task.id;
|
|
64
64
|
}
|
|
65
|
+
async function createWorkflowTask(entry) {
|
|
66
|
+
const workflow = entry.workflow;
|
|
67
|
+
if (!workflow)
|
|
68
|
+
return undefined;
|
|
69
|
+
const state = workflow.definition.states[workflow.currentState];
|
|
70
|
+
if (!state?.task || state.terminal)
|
|
71
|
+
return undefined;
|
|
72
|
+
const link = {
|
|
73
|
+
loopId: entry.id,
|
|
74
|
+
stateId: workflow.currentState,
|
|
75
|
+
transitionSeq: workflow.transitionSeq,
|
|
76
|
+
};
|
|
77
|
+
const metadata = { workflow: link };
|
|
78
|
+
if (isTasksAvailable()) {
|
|
79
|
+
try {
|
|
80
|
+
const reply = await rpcCall(pi.events, TASKS_RPC.create, {
|
|
81
|
+
subject: state.task.subject,
|
|
82
|
+
description: state.task.description,
|
|
83
|
+
metadata,
|
|
84
|
+
}, 5000);
|
|
85
|
+
return reply.id;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (isDetectionSettled && !isDetectionSettled())
|
|
92
|
+
return undefined;
|
|
93
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
94
|
+
if (!nativeTaskStore)
|
|
95
|
+
return undefined;
|
|
96
|
+
const task = nativeTaskStore.create(state.task.subject, state.task.description, metadata, link);
|
|
97
|
+
emitNativeTaskEvent(pi, "tasks:created", task);
|
|
98
|
+
onNativeTaskCreated?.(nativeTaskStore);
|
|
99
|
+
return task.id;
|
|
100
|
+
}
|
|
101
|
+
async function completeWorkflowTask(taskId) {
|
|
102
|
+
if (isTasksAvailable()) {
|
|
103
|
+
try {
|
|
104
|
+
await rpcCall(pi.events, TASKS_RPC.update, { id: taskId, status: "completed" }, 5000);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const nativeTaskStore = getNativeTaskStore();
|
|
112
|
+
const existing = nativeTaskStore?.get(taskId);
|
|
113
|
+
if (!nativeTaskStore || !existing)
|
|
114
|
+
return false;
|
|
115
|
+
if (existing.status === "completed")
|
|
116
|
+
return true;
|
|
117
|
+
const completed = nativeTaskStore.complete(taskId);
|
|
118
|
+
if (!completed)
|
|
119
|
+
return false;
|
|
120
|
+
emitNativeTaskEvent(pi, "tasks:completed", completed, existing.status);
|
|
121
|
+
await onNativeTaskCompleted?.(nativeTaskStore);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
65
124
|
async function hasPendingTasks() {
|
|
66
125
|
if (isTasksAvailable()) {
|
|
67
126
|
// -1 is this bridge's "unknown" sentinel, consumed by notification-runtime;
|
|
@@ -96,6 +155,8 @@ export function createTaskRuntimeBridge(options) {
|
|
|
96
155
|
return {
|
|
97
156
|
checkTasksVersion,
|
|
98
157
|
autoCreateTask,
|
|
158
|
+
createWorkflowTask,
|
|
159
|
+
completeWorkflowTask,
|
|
99
160
|
hasPendingTasks,
|
|
100
161
|
cleanDoneTasks,
|
|
101
162
|
};
|
package/dist/store.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
|
|
2
2
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
3
|
-
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
3
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger, WorkflowDefinition, WorkflowTerminalStatus } from "./types.js";
|
|
4
|
+
import { type WorkflowTransitionInput } from "./workflow-reducer.js";
|
|
4
5
|
export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
5
6
|
private tombstones;
|
|
6
7
|
constructor(listIdOrPath?: string);
|
|
@@ -11,6 +12,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
11
12
|
readOnly?: boolean;
|
|
12
13
|
maxFires?: number;
|
|
13
14
|
dynamic?: Partial<DynamicLoopState>;
|
|
15
|
+
workflow?: WorkflowDefinition;
|
|
14
16
|
}): LoopEntry;
|
|
15
17
|
pause(id: string): LoopEntry | undefined;
|
|
16
18
|
resume(id: string): LoopEntry | undefined;
|
|
@@ -26,6 +28,13 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
26
28
|
prompt?: string;
|
|
27
29
|
dynamic: Partial<DynamicLoopState>;
|
|
28
30
|
}): LoopEntry | undefined;
|
|
31
|
+
transitionWorkflow(id: string, input: WorkflowTransitionInput): {
|
|
32
|
+
entry?: LoopEntry;
|
|
33
|
+
applied: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
terminal?: WorkflowTerminalStatus;
|
|
36
|
+
};
|
|
37
|
+
setWorkflowActiveTask(id: string, taskId?: string): LoopEntry | undefined;
|
|
29
38
|
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
|
|
30
39
|
recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
|
|
31
40
|
delete(id: string): boolean;
|
package/dist/store.js
CHANGED
|
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { reduceLoopState } from "./loop-reducer.js";
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
|
+
import { transitionWorkflowRun, validateWorkflowDefinition } from "./workflow-reducer.js";
|
|
5
6
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
6
7
|
const MAX_LOOPS = 25;
|
|
7
8
|
const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
@@ -22,6 +23,13 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
22
23
|
if (this.entries.size >= MAX_LOOPS) {
|
|
23
24
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
24
25
|
}
|
|
26
|
+
if (opts.workflow) {
|
|
27
|
+
if (trigger.type !== "dynamic")
|
|
28
|
+
throw new Error("Workflow loops require a dynamic trigger.");
|
|
29
|
+
const validationError = validateWorkflowDefinition(opts.workflow);
|
|
30
|
+
if (validationError)
|
|
31
|
+
throw new Error(`Invalid workflow: ${validationError}`);
|
|
32
|
+
}
|
|
25
33
|
const now = Date.now();
|
|
26
34
|
this.applyReducerEvent({
|
|
27
35
|
type: "LOOP_CREATED",
|
|
@@ -37,6 +45,7 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
37
45
|
readOnly: opts.readOnly,
|
|
38
46
|
maxFires: opts.maxFires,
|
|
39
47
|
dynamic: opts.dynamic,
|
|
48
|
+
workflow: opts.workflow,
|
|
40
49
|
},
|
|
41
50
|
});
|
|
42
51
|
return this.entries.get(String(this.nextId - 1));
|
|
@@ -142,6 +151,48 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
142
151
|
return this.entries.get(id);
|
|
143
152
|
});
|
|
144
153
|
}
|
|
154
|
+
transitionWorkflow(id, input) {
|
|
155
|
+
return this.withLock(() => {
|
|
156
|
+
const entry = this.entries.get(id);
|
|
157
|
+
if (!entry)
|
|
158
|
+
return { applied: false, error: `Loop #${id} not found` };
|
|
159
|
+
if (!entry.workflow)
|
|
160
|
+
return { applied: false, error: `Loop #${id} is not a workflow loop` };
|
|
161
|
+
const result = transitionWorkflowRun(entry.workflow, input, Date.now());
|
|
162
|
+
if (!result.applied)
|
|
163
|
+
return { applied: false, error: result.error };
|
|
164
|
+
this.applyReducerEvent({
|
|
165
|
+
type: "LOOP_WORKFLOW_TRANSITION",
|
|
166
|
+
at: result.run.stateEnteredAt,
|
|
167
|
+
source: "tool",
|
|
168
|
+
entityType: "loop",
|
|
169
|
+
entityId: id,
|
|
170
|
+
payload: {
|
|
171
|
+
id,
|
|
172
|
+
outcome: input.outcome,
|
|
173
|
+
evidence: input.evidence,
|
|
174
|
+
activeTaskId: input.activeTaskId,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return { entry: this.entries.get(id), applied: true, terminal: result.terminal };
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
setWorkflowActiveTask(id, taskId) {
|
|
181
|
+
return this.withLock(() => {
|
|
182
|
+
const entry = this.entries.get(id);
|
|
183
|
+
if (!entry?.workflow)
|
|
184
|
+
return undefined;
|
|
185
|
+
this.applyReducerEvent({
|
|
186
|
+
type: "LOOP_WORKFLOW_TASK_SET",
|
|
187
|
+
at: Date.now(),
|
|
188
|
+
source: "tool",
|
|
189
|
+
entityType: "loop",
|
|
190
|
+
entityId: id,
|
|
191
|
+
payload: { id, taskId },
|
|
192
|
+
});
|
|
193
|
+
return this.entries.get(id);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
145
196
|
getDeletionTombstone(id) {
|
|
146
197
|
const tombstone = this.tombstones.get(id);
|
|
147
198
|
if (!tombstone)
|
package/dist/task-reducer.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TaskEntry } from "./task-types.js";
|
|
1
|
+
import type { TaskEntry, TaskWorkflowLink } from "./task-types.js";
|
|
2
2
|
export interface TaskReducerState {
|
|
3
3
|
nextId: number;
|
|
4
4
|
tasksById: Record<string, TaskEntry>;
|
|
@@ -13,6 +13,7 @@ export type TaskReducerEvent = {
|
|
|
13
13
|
subject: string;
|
|
14
14
|
description: string;
|
|
15
15
|
metadata?: Record<string, unknown>;
|
|
16
|
+
workflow?: TaskWorkflowLink;
|
|
16
17
|
};
|
|
17
18
|
} | {
|
|
18
19
|
type: "TASK_STARTED" | "TASK_COMPLETED" | "TASK_REOPENED" | "TASK_DELETED";
|
package/dist/task-reducer.js
CHANGED
package/dist/task-store.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
2
2
|
import { type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
|
|
3
|
-
import type { TaskEntry, TaskStoreData } from "./task-types.js";
|
|
3
|
+
import type { TaskEntry, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
|
|
4
4
|
export declare class TaskStore extends ReducerBackedStore<TaskEntry, TaskReducerState, TaskReducerEvent, TaskStoreData> {
|
|
5
5
|
constructor(listIdOrPath?: string);
|
|
6
|
-
create(subject: string, description: string, metadata?: Record<string, unknown
|
|
6
|
+
create(subject: string, description: string, metadata?: Record<string, unknown>, workflow?: TaskWorkflowLink): TaskEntry;
|
|
7
7
|
start(id: string): TaskEntry | undefined;
|
|
8
8
|
complete(id: string): TaskEntry | undefined;
|
|
9
9
|
reopen(id: string): TaskEntry | undefined;
|
package/dist/task-store.js
CHANGED
|
@@ -15,7 +15,7 @@ export class TaskStore extends ReducerBackedStore {
|
|
|
15
15
|
deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.tasks.map((t) => [t.id, t])) }),
|
|
16
16
|
}, listIdOrPath);
|
|
17
17
|
}
|
|
18
|
-
create(subject, description, metadata) {
|
|
18
|
+
create(subject, description, metadata, workflow) {
|
|
19
19
|
return this.withLock(() => {
|
|
20
20
|
if (this.entries.size >= MAX_TASKS) {
|
|
21
21
|
throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
|
|
@@ -26,7 +26,7 @@ export class TaskStore extends ReducerBackedStore {
|
|
|
26
26
|
at: now,
|
|
27
27
|
source: "tool",
|
|
28
28
|
entityType: "task",
|
|
29
|
-
payload: { subject, description, metadata },
|
|
29
|
+
payload: { subject, description, metadata, workflow },
|
|
30
30
|
});
|
|
31
31
|
return this.entries.get(String(this.nextId - 1));
|
|
32
32
|
});
|
package/dist/task-types.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
2
|
+
export interface TaskWorkflowLink {
|
|
3
|
+
loopId: string;
|
|
4
|
+
stateId: string;
|
|
5
|
+
transitionSeq: number;
|
|
6
|
+
}
|
|
2
7
|
export interface TaskEntry {
|
|
3
8
|
id: string;
|
|
4
9
|
subject: string;
|
|
@@ -8,6 +13,7 @@ export interface TaskEntry {
|
|
|
8
13
|
updatedAt: number;
|
|
9
14
|
completedAt?: number;
|
|
10
15
|
metadata?: Record<string, unknown>;
|
|
16
|
+
workflow?: TaskWorkflowLink;
|
|
11
17
|
}
|
|
12
18
|
export interface TaskStoreData {
|
|
13
19
|
nextId: number;
|