@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
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { createTask, deleteTask, updateTask, } from "../runtime/task-mutations.js";
3
- import { textResult } from "./tool-result.js";
3
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
4
+ import { displayRows, textResult } from "./tool-result.js";
4
5
  function backlogSuffix(backlog) {
5
6
  return backlog.created && backlog.entry
6
7
  ? `\nBacklog worker loop #${backlog.entry.id} created`
@@ -12,6 +13,8 @@ export function registerNativeTaskTools(options) {
12
13
  pi.registerTool({
13
14
  name: "TaskCreate",
14
15
  label: "TaskCreate",
16
+ renderCall: renderToolCall("Task", (args) => `create · ${String(toolArg(args, "subject") ?? "task").slice(0, 56)}`),
17
+ renderResult: renderToolResult,
15
18
  description: `Create a task for tracking work across turns. Use when you need to track progress on complex multi-step tasks or turn a broad user goal into a concrete backlog.
16
19
 
17
20
  Fields:
@@ -38,18 +41,32 @@ Fields:
38
41
  subject: params.subject,
39
42
  description: params.description,
40
43
  });
41
- return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
44
+ return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`, {
45
+ kind: "task",
46
+ action: "create",
47
+ tone: "success",
48
+ summary: `Task #${entry.id} pending · ${entry.subject.slice(0, 56)}`,
49
+ expanded: [
50
+ `Description: ${entry.description}`,
51
+ backlog.created && backlog.entry ? `Backlog worker: loop #${backlog.entry.id} created` : "Backlog worker: unchanged",
52
+ ],
53
+ });
42
54
  },
43
55
  });
44
56
  pi.registerTool({
45
57
  name: "TaskList",
46
58
  label: "TaskList",
59
+ renderCall: renderToolCall("Task", () => "status"),
60
+ renderResult: renderToolResult,
47
61
  description: "List all tasks with status. Use to check progress and find available work.",
48
62
  parameters: Type.Object({}),
49
63
  execute() {
50
64
  const tasks = taskStore.list();
51
- if (tasks.length === 0)
52
- return Promise.resolve(textResult("No tasks."));
65
+ if (tasks.length === 0) {
66
+ return Promise.resolve(textResult("No tasks.", {
67
+ kind: "task", action: "list", tone: "info", summary: "No tasks", expanded: ["Use TaskCreate for work that spans turns."],
68
+ }));
69
+ }
53
70
  const lines = [];
54
71
  const statuses = {
55
72
  pending: 0,
@@ -62,12 +79,20 @@ Fields:
62
79
  lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
63
80
  }
64
81
  lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
65
- return Promise.resolve(textResult(lines.join("\n")));
82
+ return Promise.resolve(textResult(lines.join("\n"), {
83
+ kind: "task",
84
+ action: "list",
85
+ tone: "info",
86
+ summary: `${tasks.length} task${tasks.length === 1 ? "" : "s"} · ${statuses.pending} pending · ${statuses.in_progress} active`,
87
+ expanded: displayRows(lines.slice(1)),
88
+ }));
66
89
  },
67
90
  });
68
91
  pi.registerTool({
69
92
  name: "TaskUpdate",
70
93
  label: "TaskUpdate",
94
+ renderCall: renderToolCall("Task", (args) => `update · #${String(toolArg(args, "id") ?? "?")}`),
95
+ renderResult: renderToolResult,
71
96
  description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
72
97
 
73
98
  Statuses: pending → in_progress → completed
@@ -91,24 +116,44 @@ Parameters: id (required), status, subject, description`,
91
116
  subject,
92
117
  description,
93
118
  });
94
- if (!result)
95
- return textResult(`Task #${id} not found`);
119
+ if (!result) {
120
+ return textResult(`Task #${id} not found`, {
121
+ kind: "task", action: "update", tone: "error", summary: `Task #${id} not found`, expanded: ["Use TaskList to find valid task IDs."],
122
+ });
123
+ }
96
124
  const statusMsg = status ? ` → ${status}` : "";
97
- return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
125
+ return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`, {
126
+ kind: "task",
127
+ action: "update",
128
+ tone: "success",
129
+ summary: `Task #${id}${status ? ` → ${status}` : " updated"}`,
130
+ expanded: [
131
+ `Subject: ${result.entry.subject}`,
132
+ `Status: ${result.entry.status}`,
133
+ result.backlog.created && result.backlog.entry ? `Backlog worker: loop #${result.backlog.entry.id} created` : "Backlog worker: unchanged",
134
+ ],
135
+ });
98
136
  },
99
137
  });
100
138
  pi.registerTool({
101
139
  name: "TaskDelete",
102
140
  label: "TaskDelete",
141
+ renderCall: renderToolCall("Task", (args) => `delete · #${String(toolArg(args, "id") ?? "?")}`),
142
+ renderResult: renderToolResult,
103
143
  description: "Delete a task by ID. Use for cleaning up completed or irrelevant tasks.",
104
144
  parameters: Type.Object({
105
145
  id: Type.String({ description: "Task ID to delete" }),
106
146
  }),
107
147
  async execute(_toolCallId, params) {
108
148
  const result = await deleteTask(mutationCtx, params.id);
109
- if (!result)
110
- return textResult(`Task #${params.id} not found`);
111
- return textResult(`Task #${params.id} deleted`);
149
+ if (!result) {
150
+ return textResult(`Task #${params.id} not found`, {
151
+ kind: "task", action: "delete", tone: "error", summary: `Task #${params.id} not found`, expanded: ["Use TaskList to find valid task IDs."],
152
+ });
153
+ }
154
+ return textResult(`Task #${params.id} deleted`, {
155
+ kind: "task", action: "delete", tone: "success", summary: `Task #${params.id} deleted`, expanded: [],
156
+ });
112
157
  },
113
158
  });
114
159
  }
@@ -1,8 +1,18 @@
1
1
  /** Plain-text tool result in the shape pi's registerTool expects. */
2
- export declare function textResult(msg: string): {
2
+ export type ToolDisplayKind = "loop" | "workflow" | "task" | "monitor";
3
+ export type ToolDisplayTone = "success" | "warning" | "error" | "info";
4
+ export interface ToolDisplayDetails {
5
+ kind: ToolDisplayKind;
6
+ action: string;
7
+ tone: ToolDisplayTone;
8
+ summary: string;
9
+ expanded?: string[];
10
+ }
11
+ export declare function displayRows(rows: string[], limit?: number): string[];
12
+ export declare function textResult(msg: string, details?: ToolDisplayDetails): {
3
13
  content: {
4
14
  type: "text";
5
15
  text: string;
6
16
  }[];
7
- details: undefined;
17
+ details: ToolDisplayDetails | undefined;
8
18
  };
@@ -1,4 +1,8 @@
1
- /** Plain-text tool result in the shape pi's registerTool expects. */
2
- export function textResult(msg) {
3
- return { content: [{ type: "text", text: msg }], details: undefined };
1
+ export function displayRows(rows, limit = 8) {
2
+ if (rows.length <= limit)
3
+ return rows;
4
+ return [...rows.slice(0, limit), `… ${rows.length - limit} more`];
5
+ }
6
+ export function textResult(msg, details) {
7
+ return { content: [{ type: "text", text: msg }], details };
4
8
  }
package/dist/types.d.ts CHANGED
@@ -40,6 +40,40 @@ export interface DynamicLoopState {
40
40
  awaitingUpdate?: boolean;
41
41
  lastUpdatedAt?: number;
42
42
  }
43
+ export type WorkflowTerminalStatus = "completed" | "paused";
44
+ export interface WorkflowTaskDefinition {
45
+ subject: string;
46
+ description: string;
47
+ }
48
+ export interface WorkflowStateDefinition {
49
+ prompt: string;
50
+ task?: WorkflowTaskDefinition;
51
+ on?: Record<string, string>;
52
+ terminal?: WorkflowTerminalStatus;
53
+ maxAttempts?: number;
54
+ }
55
+ export interface WorkflowDefinition {
56
+ version: 1;
57
+ initialState: string;
58
+ states: Record<string, WorkflowStateDefinition>;
59
+ }
60
+ export interface WorkflowTransitionRecord {
61
+ from: string;
62
+ to: string;
63
+ outcome: string;
64
+ evidence?: string;
65
+ at: number;
66
+ sequence: number;
67
+ }
68
+ export interface WorkflowRunState {
69
+ definition: WorkflowDefinition;
70
+ currentState: string;
71
+ transitionSeq: number;
72
+ stateEnteredAt: number;
73
+ attemptsByState: Record<string, number>;
74
+ activeTaskId?: string;
75
+ lastTransition?: WorkflowTransitionRecord;
76
+ }
43
77
  export interface LoopEntry {
44
78
  id: string;
45
79
  prompt: string;
@@ -55,6 +89,7 @@ export interface LoopEntry {
55
89
  maxFires?: number;
56
90
  fireCount?: number;
57
91
  dynamic?: DynamicLoopState;
92
+ workflow?: WorkflowRunState;
58
93
  }
59
94
  export interface LoopStoreData {
60
95
  nextId: number;
@@ -0,0 +1,7 @@
1
+ import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ type ToolCallArgs = object;
4
+ export declare function renderToolCall(label: string, summarize: (args: ToolCallArgs) => string): (args: ToolCallArgs, theme: Theme) => Text;
5
+ export declare function toolArg(args: ToolCallArgs, name: string): unknown;
6
+ export declare function renderToolResult(result: AgentToolResult<unknown>, { expanded, isPartial }: ToolRenderResultOptions, theme: Theme): Text;
7
+ export {};
@@ -0,0 +1,34 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ export function renderToolCall(label, summarize) {
3
+ return (args, theme) => {
4
+ const summary = summarize(args);
5
+ const text = theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("muted", summary);
6
+ return new Text(text, 0, 0);
7
+ };
8
+ }
9
+ export function toolArg(args, name) {
10
+ return args[name];
11
+ }
12
+ export function renderToolResult(result, { expanded, isPartial }, theme) {
13
+ if (isPartial)
14
+ return new Text(theme.fg("warning", "Working…"), 0, 0);
15
+ const details = result.details;
16
+ if (!details) {
17
+ const content = result.content[0];
18
+ return new Text(content?.type === "text" ? content.text : "No result", 0, 0);
19
+ }
20
+ const color = details.tone === "success"
21
+ ? "success"
22
+ : details.tone === "warning"
23
+ ? "warning"
24
+ : details.tone === "error"
25
+ ? "error"
26
+ : "muted";
27
+ const icon = details.tone === "success" ? "✓" : details.tone === "error" ? "✕" : details.tone === "warning" ? "!" : "•";
28
+ let text = theme.fg(color, `${icon} ${details.summary}`);
29
+ if (expanded && details.expanded?.length) {
30
+ for (const line of details.expanded)
31
+ text += `\n${theme.fg("dim", line)}`;
32
+ }
33
+ return new Text(text, 0, 0);
34
+ }
package/dist/ui/widget.js CHANGED
@@ -23,18 +23,18 @@ export class LoopWidget {
23
23
  }
24
24
  computeStatus() {
25
25
  const loops = this.store.list().filter(isStatusVisibleLoop);
26
- const monitors = this.monitorManager.list();
26
+ const monitors = this.monitorManager.list().filter((monitor) => monitor.status === "running");
27
27
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
28
28
  if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
29
29
  return undefined;
30
30
  }
31
31
  const parts = [];
32
32
  if (loops.length > 0)
33
- parts.push(formatCount(loops.length, "loop"));
33
+ parts.push(`↻ ${formatCount(loops.length, "loop")}`);
34
34
  if (monitors.length > 0)
35
- parts.push(formatCount(monitors.length, "monitor"));
35
+ parts.push(`▶ ${formatCount(monitors.length, "monitor")}`);
36
36
  if (taskSummary.count > 0)
37
- parts.push(formatCount(taskSummary.count, "task"));
37
+ parts.push(`□ ${formatCount(taskSummary.count, "task")}`);
38
38
  let line = parts.join(" · ");
39
39
  if (taskSummary.focusText)
40
40
  line += ` | ${taskSummary.focusText}`;
@@ -0,0 +1,18 @@
1
+ import type { WorkflowDefinition, WorkflowRunState, WorkflowTerminalStatus } from "./types.js";
2
+ export type { WorkflowDefinition, WorkflowRunState, WorkflowStateDefinition, WorkflowTerminalStatus, WorkflowTransitionRecord, } from "./types.js";
3
+ export interface WorkflowTransitionInput {
4
+ outcome: string;
5
+ evidence?: string;
6
+ activeTaskId?: string;
7
+ }
8
+ export type WorkflowTransitionResult = {
9
+ applied: true;
10
+ run: WorkflowRunState;
11
+ terminal?: WorkflowTerminalStatus;
12
+ } | {
13
+ applied: false;
14
+ error: string;
15
+ };
16
+ export declare function validateWorkflowDefinition(definition: WorkflowDefinition): string | undefined;
17
+ export declare function createWorkflowRun(definition: WorkflowDefinition, at: number): WorkflowRunState;
18
+ export declare function transitionWorkflowRun(run: WorkflowRunState, input: WorkflowTransitionInput, at: number): WorkflowTransitionResult;
@@ -0,0 +1,82 @@
1
+ export function validateWorkflowDefinition(definition) {
2
+ if (!definition || definition.version !== 1)
3
+ return "Workflow version must be 1";
4
+ if (!definition.states || typeof definition.states !== "object")
5
+ return "Workflow states must be an object";
6
+ if (!definition.initialState || !definition.states[definition.initialState]) {
7
+ return `Initial state "${definition.initialState}" is not defined`;
8
+ }
9
+ for (const [stateId, state] of Object.entries(definition.states)) {
10
+ if (!stateId)
11
+ return "Workflow state IDs must be non-empty";
12
+ if (!state || typeof state !== "object")
13
+ return `State "${stateId}" must be an object`;
14
+ if (typeof state.prompt !== "string")
15
+ return `State "${stateId}" requires a prompt`;
16
+ if (!state.prompt.trim())
17
+ return `State "${stateId}" requires a prompt`;
18
+ if (state.on !== undefined && (typeof state.on !== "object" || Array.isArray(state.on))) {
19
+ return `State "${stateId}" transitions must be an object`;
20
+ }
21
+ if (state.terminal && state.on && Object.keys(state.on).length > 0) {
22
+ return `Terminal state "${stateId}" cannot declare transitions`;
23
+ }
24
+ if (state.maxAttempts !== undefined && (!Number.isInteger(state.maxAttempts) || state.maxAttempts < 1)) {
25
+ return `State "${stateId}" maxAttempts must be a positive integer`;
26
+ }
27
+ for (const [outcome, target] of Object.entries(state.on ?? {})) {
28
+ if (!outcome)
29
+ return `State "${stateId}" has an empty outcome name`;
30
+ if (typeof target !== "string")
31
+ return `Transition "${stateId}.${outcome}" target must be a state ID`;
32
+ if (!definition.states[target])
33
+ return `Transition "${stateId}.${outcome}" targets unknown state "${target}"`;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+ export function createWorkflowRun(definition, at) {
39
+ return {
40
+ definition,
41
+ currentState: definition.initialState,
42
+ transitionSeq: 0,
43
+ stateEnteredAt: at,
44
+ attemptsByState: { [definition.initialState]: 1 },
45
+ };
46
+ }
47
+ export function transitionWorkflowRun(run, input, at) {
48
+ const current = run.definition.states[run.currentState];
49
+ if (!current)
50
+ return { applied: false, error: `Current state "${run.currentState}" is not defined` };
51
+ if (current.terminal)
52
+ return { applied: false, error: `Workflow is already ${current.terminal}` };
53
+ const target = current.on?.[input.outcome];
54
+ if (!target)
55
+ return { applied: false, error: `Outcome "${input.outcome}" is not allowed from state "${run.currentState}"` };
56
+ const targetState = run.definition.states[target];
57
+ if (!targetState)
58
+ return { applied: false, error: `Transition target "${target}" is not defined` };
59
+ const nextAttempt = (run.attemptsByState[target] ?? 0) + 1;
60
+ if (targetState.maxAttempts !== undefined && nextAttempt > targetState.maxAttempts) {
61
+ return { applied: false, error: `State "${target}" has exhausted its ${targetState.maxAttempts} attempt limit` };
62
+ }
63
+ const sequence = run.transitionSeq + 1;
64
+ const lastTransition = {
65
+ from: run.currentState,
66
+ to: target,
67
+ outcome: input.outcome,
68
+ evidence: input.evidence,
69
+ at,
70
+ sequence,
71
+ };
72
+ const nextRun = {
73
+ ...run,
74
+ currentState: target,
75
+ transitionSeq: sequence,
76
+ stateEnteredAt: at,
77
+ attemptsByState: { ...run.attemptsByState, [target]: nextAttempt },
78
+ activeTaskId: input.activeTaskId,
79
+ lastTransition,
80
+ };
81
+ return { applied: true, run: nextRun, terminal: targetState.terminal };
82
+ }
@@ -60,6 +60,47 @@ LoopUpdate id="1" status="completed"
60
60
 
61
61
  Paused dynamic loops can be resumed from the `/loop` menu. If an in-memory wake is lost during a restart or session switch, persisted dynamic state recovers it.
62
62
 
63
+ ### Opt-in workflow loops
64
+
65
+ Use a workflow only when work has stable named phases and outcomes. Ordinary reminders, polling, event hooks, and flat task backlogs should continue to use `LoopCreate` or the task tools.
66
+
67
+ ```text
68
+ WorkflowCreate goal="Fix the regression" definition='{
69
+ "version": 1,
70
+ "initialState": "investigate",
71
+ "states": {
72
+ "investigate": {
73
+ "prompt": "Find and verify the root cause.",
74
+ "task": { "subject": "Investigate regression", "description": "Find the root cause and reproduce it." },
75
+ "on": { "root_cause_found": "fix", "blocked": "blocked" }
76
+ },
77
+ "fix": {
78
+ "prompt": "Implement and validate the fix.",
79
+ "task": { "subject": "Implement fix", "description": "Make the smallest fix and run targeted validation." },
80
+ "on": { "tests_pass": "done", "regression_found": "investigate" },
81
+ "maxAttempts": 2
82
+ },
83
+ "done": { "prompt": "Report completion.", "terminal": "completed" },
84
+ "blocked": { "prompt": "Report the blocker.", "terminal": "paused" }
85
+ }
86
+ }'
87
+ ```
88
+
89
+ Each wake presents the current state, state instructions, active task, and allowed outcomes. The agent finishes the state by selecting one declared outcome:
90
+
91
+ ```text
92
+ WorkflowTransition id="1" outcome="root_cause_found" evidence="A null config reaches the parser."
93
+ WorkflowTransition id="1" outcome="tests_pass" evidence="Targeted and full test suites pass."
94
+ ```
95
+
96
+ `WorkflowTransition` validates the branch, records evidence, creates the next state's optional task, and queues the next wake. Reaching a `completed` terminal state deletes the workflow loop; reaching a `paused` terminal state preserves it in paused state. Task completion does not guess an outcome—the model selects one explicitly.
97
+
98
+ ```text
99
+ WorkflowList
100
+ ```
101
+
102
+ Lists only workflow loops, including their active state, active task, and valid outcomes.
103
+
63
104
  ### Inspecting and stopping loops
64
105
 
65
106
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trevonistrevon/pi-loop",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
5
5
  "author": "trevonistrevon",
6
6
  "license": "MIT",
@@ -31,8 +31,8 @@
31
31
  "./package.json": "./package.json"
32
32
  },
33
33
  "peerDependencies": {
34
- "@earendil-works/pi-coding-agent": "^0.80.10",
35
- "@earendil-works/pi-tui": "^0.80.10"
34
+ "@earendil-works/pi-coding-agent": "^0.81.1",
35
+ "@earendil-works/pi-tui": "^0.81.1"
36
36
  },
37
37
  "dependencies": {
38
38
  "typebox": "^1.1.34"
package/src/api.ts CHANGED
@@ -33,4 +33,12 @@ export {
33
33
  export { NATIVE_TASKS_PROVIDER } from "./runtime/native-task-rpc.js";
34
34
  export { resolveLoopStorePath, resolveTaskStorePath } from "./runtime/scope.js";
35
35
  export { TaskStore } from "./task-store.js";
36
- export type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
36
+ export type { TaskEntry, TaskStatus, TaskStoreData, TaskWorkflowLink } from "./task-types.js";
37
+ export type {
38
+ WorkflowDefinition,
39
+ WorkflowRunState,
40
+ WorkflowStateDefinition,
41
+ WorkflowTaskDefinition,
42
+ WorkflowTerminalStatus,
43
+ WorkflowTransitionRecord,
44
+ } from "./types.js";
package/src/index.ts CHANGED
@@ -128,6 +128,10 @@ export default function (pi: ExtensionAPI) {
128
128
  onDetectionSettled: () => {
129
129
  tasksDetectionSettled = true;
130
130
  },
131
+ isDetectionSettled: () => tasksDetectionSettled,
132
+ onNativeTaskCompleted: () => {
133
+ widget.update();
134
+ },
131
135
  debug,
132
136
  });
133
137
 
@@ -282,6 +286,7 @@ export default function (pi: ExtensionAPI) {
282
286
  autoTask: firedEntry.autoTask,
283
287
  taskBacklog: firedEntry.taskBacklog,
284
288
  dynamic: firedEntry.dynamic,
289
+ workflow: firedEntry.workflow,
285
290
  });
286
291
  }
287
292
 
@@ -349,6 +354,11 @@ export default function (pi: ExtensionAPI) {
349
354
  },
350
355
  maybeBootstrapTaskLoop,
351
356
  isTaskSystemReady: () => tasksAvailable || nativeTasksRegistered,
357
+ onDynamicLoopActivated: (entry) => {
358
+ onLoopFire(entry);
359
+ },
360
+ createWorkflowTask: (entry) => taskRuntime.createWorkflowTask(entry),
361
+ completeWorkflowTask: (taskId) => taskRuntime.completeWorkflowTask(taskId),
352
362
  });
353
363
 
354
364
  function handleMonitorDoneLoop(doneLoop: LoopEntry, monitorId: string): void {
@@ -1,4 +1,5 @@
1
- import type { DynamicLoopState, LoopEntry, Trigger } from "./types.js";
1
+ import type { DynamicLoopState, LoopEntry, Trigger, WorkflowDefinition } from "./types.js";
2
+ import { createWorkflowRun, transitionWorkflowRun } from "./workflow-reducer.js";
2
3
 
3
4
  export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
4
5
 
@@ -35,6 +36,7 @@ export type LoopReducerEvent =
35
36
  readOnly?: boolean;
36
37
  maxFires?: number;
37
38
  dynamic?: Partial<DynamicLoopState>;
39
+ workflow?: WorkflowDefinition;
38
40
  };
39
41
  }
40
42
  | {
@@ -73,6 +75,27 @@ export type LoopReducerEvent =
73
75
  prompt?: string;
74
76
  dynamic: Partial<DynamicLoopState>;
75
77
  };
78
+ }
79
+ | {
80
+ type: "LOOP_WORKFLOW_TRANSITION";
81
+ at: number;
82
+ source: ReducerSource;
83
+ entityType?: "loop";
84
+ entityId?: string;
85
+ payload: {
86
+ id: string;
87
+ outcome: string;
88
+ evidence?: string;
89
+ activeTaskId?: string;
90
+ };
91
+ }
92
+ | {
93
+ type: "LOOP_WORKFLOW_TASK_SET";
94
+ at: number;
95
+ source: ReducerSource;
96
+ entityType?: "loop";
97
+ entityId?: string;
98
+ payload: { id: string; taskId?: string };
76
99
  };
77
100
 
78
101
  export type LoopReducerEffect =
@@ -131,6 +154,7 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
131
154
  lastUpdatedAt: event.payload.dynamic?.lastUpdatedAt ?? event.at,
132
155
  }
133
156
  : undefined,
157
+ workflow: event.payload.workflow ? createWorkflowRun(event.payload.workflow, event.at) : undefined,
134
158
  };
135
159
  next.loopsById[id] = loop;
136
160
  return {
@@ -190,6 +214,34 @@ export function reduceLoopState(state: LoopReducerState, event: LoopReducerEvent
190
214
  loop.updatedAt = event.at;
191
215
  }
192
216
 
217
+ if (event.type === "LOOP_WORKFLOW_TRANSITION") {
218
+ if (!loop.workflow) return { state, effects: [] };
219
+ const result = transitionWorkflowRun(loop.workflow, {
220
+ outcome: event.payload.outcome,
221
+ evidence: event.payload.evidence,
222
+ activeTaskId: event.payload.activeTaskId,
223
+ }, event.at);
224
+ if (!result.applied) return { state, effects: [] };
225
+ loop.workflow = result.run;
226
+ loop.dynamic = {
227
+ goal: loop.dynamic?.goal ?? loop.prompt,
228
+ state: result.run.currentState,
229
+ metrics: loop.dynamic?.metrics,
230
+ doneCriteria: loop.dynamic?.doneCriteria,
231
+ iteration: (loop.dynamic?.iteration ?? 0) + 1,
232
+ nextWakeAt: undefined,
233
+ awaitingUpdate: false,
234
+ lastUpdatedAt: event.at,
235
+ };
236
+ loop.updatedAt = event.at;
237
+ }
238
+
239
+ if (event.type === "LOOP_WORKFLOW_TASK_SET") {
240
+ if (!loop.workflow) return { state, effects: [] };
241
+ loop.workflow = { ...loop.workflow, activeTaskId: event.payload.taskId };
242
+ loop.updatedAt = event.at;
243
+ }
244
+
193
245
  next.loopsById[id] = loop;
194
246
  return {
195
247
  state: next,
@@ -1,4 +1,4 @@
1
- import type { DynamicLoopState } from "./types.js";
1
+ import type { DynamicLoopState, WorkflowRunState } from "./types.js";
2
2
 
3
3
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
4
4
 
@@ -14,6 +14,7 @@ export interface ReducerNotification {
14
14
  taskBacklog?: boolean;
15
15
  readOnly?: boolean;
16
16
  dynamic?: DynamicLoopState;
17
+ workflow?: WorkflowRunState;
17
18
  }
18
19
 
19
20
  export interface NotificationReducerState {
@@ -12,7 +12,7 @@ import {
12
12
  type ReducerNotification,
13
13
  reduceNotificationState,
14
14
  } from "../notification-reducer.js";
15
- import type { DynamicLoopState, Trigger } from "../types.js";
15
+ import type { DynamicLoopState, Trigger, WorkflowRunState } from "../types.js";
16
16
 
17
17
  export interface LoopFireEvent {
18
18
  loopId: string;
@@ -25,6 +25,7 @@ export interface LoopFireEvent {
25
25
  autoTask?: boolean;
26
26
  taskBacklog?: boolean;
27
27
  dynamic?: DynamicLoopState;
28
+ workflow?: WorkflowRunState;
28
29
  }
29
30
 
30
31
  export interface PendingNotification extends LoopFireEvent {
@@ -106,6 +107,24 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
106
107
  ? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
107
108
  : "";
108
109
 
110
+ if (data.workflow) {
111
+ const state = data.workflow.definition.states[data.workflow.currentState];
112
+ const outcomes = Object.keys(state?.on ?? {});
113
+ const lines = [
114
+ `[pi-loop] Loop #${loopId} fired (workflow).${constraint}`,
115
+ `Goal: ${data.prompt || data.workflow.definition.initialState}`,
116
+ `State: ${data.workflow.currentState}`,
117
+ ];
118
+ if (state?.prompt) lines.push(`State instructions: ${state.prompt}`);
119
+ if (data.workflow.activeTaskId) lines.push(`Active task: #${data.workflow.activeTaskId}`);
120
+ if (outcomes.length > 0) lines.push(`Allowed outcomes: ${outcomes.join(", ")}`);
121
+ lines.push(
122
+ `Workflow lifecycle: Loop #${loopId} is an opt-in state controller. Do not call LoopDelete after this state.`,
123
+ "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.",
124
+ );
125
+ return lines.join("\n");
126
+ }
127
+
109
128
  if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
110
129
  const dynamic = data.dynamic;
111
130
  const lines = [
@@ -169,6 +188,7 @@ export function createNotificationRuntime(options: NotificationRuntimeOptions):
169
188
  autoTask: notification.autoTask,
170
189
  taskBacklog: notification.taskBacklog,
171
190
  dynamic: notification.dynamic,
191
+ workflow: notification.workflow,
172
192
  timestamp: notification.timestamp,
173
193
  },
174
194
  }, {
@@ -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
 
4
4
  export type NativeTaskEventName =
5
5
  | "tasks:created"
@@ -19,6 +19,7 @@ export interface NativeTaskEventPayload {
19
19
  updatedAt: number;
20
20
  completedAt?: number;
21
21
  metadata?: Record<string, unknown>;
22
+ workflow?: TaskWorkflowLink;
22
23
  }
23
24
 
24
25
  export function emitNativeTaskEvent(
@@ -37,5 +38,6 @@ export function emitNativeTaskEvent(
37
38
  updatedAt: entry.updatedAt,
38
39
  completedAt: entry.completedAt,
39
40
  metadata: entry.metadata,
41
+ workflow: entry.workflow,
40
42
  } satisfies NativeTaskEventPayload);
41
43
  }