@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -2
  3. package/dist/api.d.ts +2 -1
  4. package/dist/index.js +14 -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 +4 -1
  8. package/dist/runtime/notification-runtime.d.ts +4 -1
  9. package/dist/runtime/notification-runtime.js +27 -1
  10. package/dist/runtime/session-runtime.js +14 -3
  11. package/dist/runtime/task-backlog-runtime.d.ts +1 -1
  12. package/dist/runtime/task-backlog-runtime.js +1 -1
  13. package/dist/runtime/task-events.d.ts +2 -1
  14. package/dist/runtime/task-events.js +1 -0
  15. package/dist/runtime/task-rpc.d.ts +4 -0
  16. package/dist/runtime/task-rpc.js +62 -1
  17. package/dist/store.d.ts +10 -1
  18. package/dist/store.js +51 -0
  19. package/dist/task-reducer.d.ts +2 -1
  20. package/dist/task-reducer.js +1 -0
  21. package/dist/task-store.d.ts +2 -2
  22. package/dist/task-store.js +2 -2
  23. package/dist/task-types.d.ts +6 -0
  24. package/dist/tools/loop-tools.d.ts +17 -1
  25. package/dist/tools/loop-tools.js +331 -26
  26. package/dist/tools/monitor-tools.js +43 -9
  27. package/dist/tools/native-task-tools.js +56 -11
  28. package/dist/tools/tool-result.d.ts +12 -2
  29. package/dist/tools/tool-result.js +7 -3
  30. package/dist/types.d.ts +35 -0
  31. package/dist/ui/tool-renderer.d.ts +7 -0
  32. package/dist/ui/tool-renderer.js +34 -0
  33. package/dist/ui/widget.js +4 -4
  34. package/dist/workflow-reducer.d.ts +18 -0
  35. package/dist/workflow-reducer.js +82 -0
  36. package/docs/USAGE_GUIDE.md +41 -0
  37. package/package.json +3 -3
  38. package/src/api.ts +9 -1
  39. package/src/index.ts +14 -0
  40. package/src/loop-reducer.ts +53 -1
  41. package/src/notification-reducer.ts +4 -1
  42. package/src/runtime/notification-runtime.ts +34 -2
  43. package/src/runtime/session-runtime.ts +15 -3
  44. package/src/runtime/task-backlog-runtime.ts +1 -1
  45. package/src/runtime/task-events.ts +3 -1
  46. package/src/runtime/task-rpc.ts +66 -0
  47. package/src/store.ts +51 -2
  48. package/src/task-reducer.ts +3 -1
  49. package/src/task-store.ts +3 -3
  50. package/src/task-types.ts +7 -0
  51. package/src/tools/loop-tools.ts +376 -20
  52. package/src/tools/monitor-tools.ts +44 -7
  53. package/src/tools/native-task-tools.ts +56 -8
  54. package/src/tools/tool-result.ts +18 -2
  55. package/src/types.ts +41 -0
  56. package/src/ui/tool-renderer.ts +45 -0
  57. package/src/ui/widget.ts +4 -4
  58. 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, 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
@@ -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);
@@ -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;
@@ -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("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
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; the next tick retries.
19
- void pumpLoops().catch(() => { });
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, call LoopDelete on your own loop ID.";
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, call LoopDelete on your own loop ID.";
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;
@@ -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;