@trevonistrevon/pi-loop 0.6.3 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -2
  3. package/dist/api.d.ts +2 -1
  4. package/dist/index.js +10 -0
  5. package/dist/loop-reducer.d.ts +24 -1
  6. package/dist/loop-reducer.js +31 -0
  7. package/dist/notification-reducer.d.ts +2 -1
  8. package/dist/runtime/notification-runtime.d.ts +2 -1
  9. package/dist/runtime/notification-runtime.js +18 -0
  10. package/dist/runtime/task-events.d.ts +2 -1
  11. package/dist/runtime/task-events.js +1 -0
  12. package/dist/runtime/task-rpc.d.ts +4 -0
  13. package/dist/runtime/task-rpc.js +62 -1
  14. package/dist/store.d.ts +10 -1
  15. package/dist/store.js +51 -0
  16. package/dist/task-reducer.d.ts +2 -1
  17. package/dist/task-reducer.js +1 -0
  18. package/dist/task-store.d.ts +2 -2
  19. package/dist/task-store.js +2 -2
  20. package/dist/task-types.d.ts +6 -0
  21. package/dist/tools/loop-tools.d.ts +17 -1
  22. package/dist/tools/loop-tools.js +320 -21
  23. package/dist/tools/monitor-tools.js +43 -9
  24. package/dist/tools/native-task-tools.js +56 -11
  25. package/dist/tools/tool-result.d.ts +12 -2
  26. package/dist/tools/tool-result.js +7 -3
  27. package/dist/types.d.ts +35 -0
  28. package/dist/ui/tool-renderer.d.ts +7 -0
  29. package/dist/ui/tool-renderer.js +34 -0
  30. package/dist/ui/widget.js +4 -4
  31. package/dist/workflow-reducer.d.ts +18 -0
  32. package/dist/workflow-reducer.js +82 -0
  33. package/docs/USAGE_GUIDE.md +41 -0
  34. package/package.json +3 -3
  35. package/src/api.ts +9 -1
  36. package/src/index.ts +10 -0
  37. package/src/loop-reducer.ts +53 -1
  38. package/src/notification-reducer.ts +2 -1
  39. package/src/runtime/notification-runtime.ts +21 -1
  40. package/src/runtime/task-events.ts +3 -1
  41. package/src/runtime/task-rpc.ts +66 -0
  42. package/src/store.ts +51 -2
  43. package/src/task-reducer.ts +3 -1
  44. package/src/task-store.ts +3 -3
  45. package/src/task-types.ts +7 -0
  46. package/src/tools/loop-tools.ts +365 -15
  47. package/src/tools/monitor-tools.ts +44 -7
  48. package/src/tools/native-task-tools.ts +56 -8
  49. package/src/tools/tool-result.ts +18 -2
  50. package/src/types.ts +41 -0
  51. package/src/ui/tool-renderer.ts +45 -0
  52. package/src/ui/widget.ts +4 -4
  53. package/src/workflow-reducer.ts +107 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## [0.6.3](https://github.com/trvon/pi-loop/compare/pi-loop-v0.6.2...pi-loop-v0.6.3) (2026-07-17)
4
18
 
5
19
 
package/README.md CHANGED
@@ -38,7 +38,7 @@ MonitorStop monitorId="1"
38
38
 
39
39
  ## What it provides
40
40
 
41
- - Cron, event, hybrid, and dynamic goal loops
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
@@ -256,6 +260,7 @@ export default function (pi) {
256
260
  autoTask: firedEntry.autoTask,
257
261
  taskBacklog: firedEntry.taskBacklog,
258
262
  dynamic: firedEntry.dynamic,
263
+ workflow: firedEntry.workflow,
259
264
  });
260
265
  }
261
266
  // ── Session lifecycle ──
@@ -315,6 +320,11 @@ export default function (pi) {
315
320
  },
316
321
  maybeBootstrapTaskLoop,
317
322
  isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
323
+ onDynamicLoopActivated: (entry) => {
324
+ onLoopFire(entry);
325
+ },
326
+ createWorkflowTask: (entry) => taskRuntime.createWorkflowTask(entry),
327
+ completeWorkflowTask: (taskId) => taskRuntime.completeWorkflowTask(taskId),
318
328
  });
319
329
  function handleMonitorDoneLoop(doneLoop, monitorId) {
320
330
  monitorOnDoneRuntime.register(doneLoop, monitorId);
@@ -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";
@@ -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;
@@ -12,6 +12,7 @@ export interface ReducerNotification {
12
12
  taskBacklog?: boolean;
13
13
  readOnly?: boolean;
14
14
  dynamic?: DynamicLoopState;
15
+ workflow?: WorkflowRunState;
15
16
  }
16
17
  export interface NotificationReducerState {
17
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;
@@ -11,6 +11,7 @@ export interface LoopFireEvent {
11
11
  autoTask?: boolean;
12
12
  taskBacklog?: boolean;
13
13
  dynamic?: DynamicLoopState;
14
+ workflow?: WorkflowRunState;
14
15
  }
15
16
  export interface PendingNotification extends LoopFireEvent {
16
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 = [
@@ -108,6 +125,7 @@ export function createNotificationRuntime(options) {
108
125
  autoTask: notification.autoTask,
109
126
  taskBacklog: notification.taskBacklog,
110
127
  dynamic: notification.dynamic,
128
+ workflow: notification.workflow,
111
129
  timestamp: notification.timestamp,
112
130
  },
113
131
  }, {
@@ -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;
@@ -9,5 +9,6 @@ export function emitNativeTaskEvent(pi, name, entry, previousStatus) {
9
9
  updatedAt: entry.updatedAt,
10
10
  completedAt: entry.completedAt,
11
11
  metadata: entry.metadata,
12
+ workflow: entry.workflow,
12
13
  });
13
14
  }
@@ -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
  }
@@ -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)
@@ -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";
@@ -16,6 +16,7 @@ export function reduceTaskState(state, event) {
16
16
  createdAt: event.at,
17
17
  updatedAt: event.at,
18
18
  metadata: event.payload.metadata,
19
+ workflow: event.payload.workflow,
19
20
  };
20
21
  next.tasksById[id] = task;
21
22
  return {
@@ -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>): TaskEntry;
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;
@@ -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
  });
@@ -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;
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { LoopEntry, Trigger, WorkflowDefinition } from "../types.js";
3
3
  interface LoopStoreLike {
4
4
  list(): LoopEntry[];
5
5
  get(id: string): LoopEntry | undefined;
@@ -9,12 +9,25 @@ interface LoopStoreLike {
9
9
  taskBacklog?: boolean;
10
10
  readOnly?: boolean;
11
11
  maxFires?: number;
12
+ dynamic?: Partial<NonNullable<LoopEntry["dynamic"]>>;
13
+ workflow?: WorkflowDefinition;
12
14
  }): LoopEntry;
13
15
  pause(id: string): LoopEntry | undefined;
14
16
  updateDynamic(id: string, fields: {
15
17
  prompt?: string;
16
18
  dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
17
19
  }): LoopEntry | undefined;
20
+ transitionWorkflow(id: string, input: {
21
+ outcome: string;
22
+ evidence?: string;
23
+ activeTaskId?: string;
24
+ }): {
25
+ entry?: LoopEntry;
26
+ applied: boolean;
27
+ error?: string;
28
+ terminal?: "completed" | "paused";
29
+ };
30
+ setWorkflowActiveTask(id: string, taskId?: string): LoopEntry | undefined;
18
31
  getDeletionTombstone(id: string): {
19
32
  reason: string;
20
33
  pendingCount?: number;
@@ -44,6 +57,9 @@ export interface LoopToolsOptions {
44
57
  updateWidget: () => void;
45
58
  maybeBootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
46
59
  isTaskSystemReady: () => boolean;
60
+ onDynamicLoopActivated?: (entry: LoopEntry) => void;
61
+ createWorkflowTask?: (entry: LoopEntry) => Promise<string | undefined>;
62
+ completeWorkflowTask?: (taskId: string) => Promise<boolean>;
47
63
  }
48
64
  export declare function registerLoopTools(options: LoopToolsOptions): void;
49
65
  export {};