@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
@@ -9,7 +9,8 @@ import {
9
9
  } from "../runtime/task-mutations.js";
10
10
  import { TaskStore } from "../task-store.js";
11
11
  import type { TaskStatus } from "../task-types.js";
12
- import { textResult } from "./tool-result.js";
12
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
13
+ import { displayRows, textResult } from "./tool-result.js";
13
14
 
14
15
  export type { TaskBacklogResult };
15
16
 
@@ -33,6 +34,8 @@ export function registerNativeTaskTools(options: NativeTaskToolsOptions): void {
33
34
  pi.registerTool({
34
35
  name: "TaskCreate",
35
36
  label: "TaskCreate",
37
+ renderCall: renderToolCall("Task", (args) => `create · ${String(toolArg(args, "subject") ?? "task").slice(0, 56)}`),
38
+ renderResult: renderToolResult,
36
39
  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.
37
40
 
38
41
  Fields:
@@ -59,18 +62,33 @@ Fields:
59
62
  subject: params.subject,
60
63
  description: params.description,
61
64
  });
62
- return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`);
65
+ return textResult(`Task #${entry.id} created: ${entry.subject}${backlogSuffix(backlog)}`, {
66
+ kind: "task",
67
+ action: "create",
68
+ tone: "success",
69
+ summary: `Task #${entry.id} pending · ${entry.subject.slice(0, 56)}`,
70
+ expanded: [
71
+ `Description: ${entry.description}`,
72
+ backlog.created && backlog.entry ? `Backlog worker: loop #${backlog.entry.id} created` : "Backlog worker: unchanged",
73
+ ],
74
+ });
63
75
  },
64
76
  });
65
77
 
66
78
  pi.registerTool({
67
79
  name: "TaskList",
68
80
  label: "TaskList",
81
+ renderCall: renderToolCall("Task", () => "status"),
82
+ renderResult: renderToolResult,
69
83
  description: "List all tasks with status. Use to check progress and find available work.",
70
84
  parameters: Type.Object({}),
71
85
  execute() {
72
86
  const tasks = taskStore.list();
73
- if (tasks.length === 0) return Promise.resolve(textResult("No tasks."));
87
+ if (tasks.length === 0) {
88
+ return Promise.resolve(textResult("No tasks.", {
89
+ kind: "task", action: "list", tone: "info", summary: "No tasks", expanded: ["Use TaskCreate for work that spans turns."],
90
+ }));
91
+ }
74
92
 
75
93
  const lines: string[] = [];
76
94
  const statuses: Record<"pending" | "in_progress" | "completed", number> = {
@@ -84,13 +102,21 @@ Fields:
84
102
  lines.push(`${icon} #${t.id} [${t.status}] ${t.subject.slice(0, 80)}`);
85
103
  }
86
104
  lines.unshift(`${tasks.length} tasks (${statuses.pending} pending, ${statuses.in_progress} in progress, ${statuses.completed} done)`);
87
- return Promise.resolve(textResult(lines.join("\n")));
105
+ return Promise.resolve(textResult(lines.join("\n"), {
106
+ kind: "task",
107
+ action: "list",
108
+ tone: "info",
109
+ summary: `${tasks.length} task${tasks.length === 1 ? "" : "s"} · ${statuses.pending} pending · ${statuses.in_progress} active`,
110
+ expanded: displayRows(lines.slice(1)),
111
+ }));
88
112
  },
89
113
  });
90
114
 
91
115
  pi.registerTool({
92
116
  name: "TaskUpdate",
93
117
  label: "TaskUpdate",
118
+ renderCall: renderToolCall("Task", (args) => `update · #${String(toolArg(args, "id") ?? "?")}`),
119
+ renderResult: renderToolResult,
94
120
  description: `Update task status or details. Set status to "in_progress" before starting work, "completed" when done.
95
121
 
96
122
  Statuses: pending → in_progress → completed
@@ -114,23 +140,45 @@ Parameters: id (required), status, subject, description`,
114
140
  subject,
115
141
  description,
116
142
  });
117
- if (!result) return textResult(`Task #${id} not found`);
143
+ if (!result) {
144
+ return textResult(`Task #${id} not found`, {
145
+ kind: "task", action: "update", tone: "error", summary: `Task #${id} not found`, expanded: ["Use TaskList to find valid task IDs."],
146
+ });
147
+ }
118
148
  const statusMsg = status ? ` → ${status}` : "";
119
- return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`);
149
+ return textResult(`Task #${id} updated${statusMsg}${backlogSuffix(result.backlog)}`, {
150
+ kind: "task",
151
+ action: "update",
152
+ tone: "success",
153
+ summary: `Task #${id}${status ? ` → ${status}` : " updated"}`,
154
+ expanded: [
155
+ `Subject: ${result.entry.subject}`,
156
+ `Status: ${result.entry.status}`,
157
+ result.backlog.created && result.backlog.entry ? `Backlog worker: loop #${result.backlog.entry.id} created` : "Backlog worker: unchanged",
158
+ ],
159
+ });
120
160
  },
121
161
  });
122
162
 
123
163
  pi.registerTool({
124
164
  name: "TaskDelete",
125
165
  label: "TaskDelete",
166
+ renderCall: renderToolCall("Task", (args) => `delete · #${String(toolArg(args, "id") ?? "?")}`),
167
+ renderResult: renderToolResult,
126
168
  description: "Delete a task by ID. Use for cleaning up completed or irrelevant tasks.",
127
169
  parameters: Type.Object({
128
170
  id: Type.String({ description: "Task ID to delete" }),
129
171
  }),
130
172
  async execute(_toolCallId, params) {
131
173
  const result = await deleteTask(mutationCtx, params.id);
132
- if (!result) return textResult(`Task #${params.id} not found`);
133
- return textResult(`Task #${params.id} deleted`);
174
+ if (!result) {
175
+ return textResult(`Task #${params.id} not found`, {
176
+ kind: "task", action: "delete", tone: "error", summary: `Task #${params.id} not found`, expanded: ["Use TaskList to find valid task IDs."],
177
+ });
178
+ }
179
+ return textResult(`Task #${params.id} deleted`, {
180
+ kind: "task", action: "delete", tone: "success", summary: `Task #${params.id} deleted`, expanded: [],
181
+ });
134
182
  },
135
183
  });
136
184
  }
@@ -1,4 +1,20 @@
1
1
  /** Plain-text tool result in the shape pi's registerTool expects. */
2
- export function textResult(msg: string) {
3
- return { content: [{ type: "text" as const, text: msg }], details: undefined };
2
+ export type ToolDisplayKind = "loop" | "workflow" | "task" | "monitor";
3
+ export type ToolDisplayTone = "success" | "warning" | "error" | "info";
4
+
5
+ export interface ToolDisplayDetails {
6
+ kind: ToolDisplayKind;
7
+ action: string;
8
+ tone: ToolDisplayTone;
9
+ summary: string;
10
+ expanded?: string[];
11
+ }
12
+
13
+ export function displayRows(rows: string[], limit = 8): string[] {
14
+ if (rows.length <= limit) return rows;
15
+ return [...rows.slice(0, limit), `… ${rows.length - limit} more`];
16
+ }
17
+
18
+ export function textResult(msg: string, details?: ToolDisplayDetails) {
19
+ return { content: [{ type: "text" as const, text: msg }], details };
4
20
  }
package/src/types.ts CHANGED
@@ -47,6 +47,46 @@ export interface DynamicLoopState {
47
47
  lastUpdatedAt?: number;
48
48
  }
49
49
 
50
+ export type WorkflowTerminalStatus = "completed" | "paused";
51
+
52
+ export interface WorkflowTaskDefinition {
53
+ subject: string;
54
+ description: string;
55
+ }
56
+
57
+ export interface WorkflowStateDefinition {
58
+ prompt: string;
59
+ task?: WorkflowTaskDefinition;
60
+ on?: Record<string, string>;
61
+ terminal?: WorkflowTerminalStatus;
62
+ maxAttempts?: number;
63
+ }
64
+
65
+ export interface WorkflowDefinition {
66
+ version: 1;
67
+ initialState: string;
68
+ states: Record<string, WorkflowStateDefinition>;
69
+ }
70
+
71
+ export interface WorkflowTransitionRecord {
72
+ from: string;
73
+ to: string;
74
+ outcome: string;
75
+ evidence?: string;
76
+ at: number;
77
+ sequence: number;
78
+ }
79
+
80
+ export interface WorkflowRunState {
81
+ definition: WorkflowDefinition;
82
+ currentState: string;
83
+ transitionSeq: number;
84
+ stateEnteredAt: number;
85
+ attemptsByState: Record<string, number>;
86
+ activeTaskId?: string;
87
+ lastTransition?: WorkflowTransitionRecord;
88
+ }
89
+
50
90
  export interface LoopEntry {
51
91
  id: string;
52
92
  prompt: string;
@@ -62,6 +102,7 @@ export interface LoopEntry {
62
102
  maxFires?: number;
63
103
  fireCount?: number;
64
104
  dynamic?: DynamicLoopState;
105
+ workflow?: WorkflowRunState;
65
106
  }
66
107
 
67
108
  export interface LoopStoreData {
@@ -0,0 +1,45 @@
1
+ import type { AgentToolResult, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { ToolDisplayDetails } from "../tools/tool-result.js";
4
+
5
+ type ToolCallArgs = object;
6
+
7
+ export function renderToolCall(label: string, summarize: (args: ToolCallArgs) => string) {
8
+ return (args: ToolCallArgs, theme: Theme) => {
9
+ const summary = summarize(args);
10
+ const text = theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("muted", summary);
11
+ return new Text(text, 0, 0);
12
+ };
13
+ }
14
+
15
+ export function toolArg(args: ToolCallArgs, name: string): unknown {
16
+ return (args as Record<string, unknown>)[name];
17
+ }
18
+
19
+ export function renderToolResult(
20
+ result: AgentToolResult<unknown>,
21
+ { expanded, isPartial }: ToolRenderResultOptions,
22
+ theme: Theme,
23
+ ) {
24
+ if (isPartial) return new Text(theme.fg("warning", "Working…"), 0, 0);
25
+
26
+ const details = result.details as ToolDisplayDetails | undefined;
27
+ if (!details) {
28
+ const content = result.content[0];
29
+ return new Text(content?.type === "text" ? content.text : "No result", 0, 0);
30
+ }
31
+
32
+ const color = details.tone === "success"
33
+ ? "success"
34
+ : details.tone === "warning"
35
+ ? "warning"
36
+ : details.tone === "error"
37
+ ? "error"
38
+ : "muted";
39
+ const icon = details.tone === "success" ? "✓" : details.tone === "error" ? "✕" : details.tone === "warning" ? "!" : "•";
40
+ let text = theme.fg(color, `${icon} ${details.summary}`);
41
+ if (expanded && details.expanded?.length) {
42
+ for (const line of details.expanded) text += `\n${theme.fg("dim", line)}`;
43
+ }
44
+ return new Text(text, 0, 0);
45
+ }
package/src/ui/widget.ts CHANGED
@@ -36,7 +36,7 @@ export class LoopWidget {
36
36
 
37
37
  private computeStatus(): string | undefined {
38
38
  const loops = this.store.list().filter(isStatusVisibleLoop);
39
- const monitors = this.monitorManager.list();
39
+ const monitors = this.monitorManager.list().filter((monitor) => monitor.status === "running");
40
40
  const taskSummary = this.taskSummaryProvider?.() ?? { count: 0 };
41
41
 
42
42
  if (loops.length === 0 && monitors.length === 0 && taskSummary.count === 0) {
@@ -44,9 +44,9 @@ export class LoopWidget {
44
44
  }
45
45
 
46
46
  const parts: string[] = [];
47
- if (loops.length > 0) parts.push(formatCount(loops.length, "loop"));
48
- if (monitors.length > 0) parts.push(formatCount(monitors.length, "monitor"));
49
- if (taskSummary.count > 0) parts.push(formatCount(taskSummary.count, "task"));
47
+ if (loops.length > 0) parts.push(`↻ ${formatCount(loops.length, "loop")}`);
48
+ if (monitors.length > 0) parts.push(`▶ ${formatCount(monitors.length, "monitor")}`);
49
+ if (taskSummary.count > 0) parts.push(`□ ${formatCount(taskSummary.count, "task")}`);
50
50
 
51
51
  let line = parts.join(" · ");
52
52
  if (taskSummary.focusText) line += ` | ${taskSummary.focusText}`;
@@ -0,0 +1,107 @@
1
+ import type {
2
+ WorkflowDefinition,
3
+ WorkflowRunState,
4
+ WorkflowTerminalStatus,
5
+ WorkflowTransitionRecord,
6
+ } from "./types.js";
7
+
8
+ export type {
9
+ WorkflowDefinition,
10
+ WorkflowRunState,
11
+ WorkflowStateDefinition,
12
+ WorkflowTerminalStatus,
13
+ WorkflowTransitionRecord,
14
+ } from "./types.js";
15
+
16
+ export interface WorkflowTransitionInput {
17
+ outcome: string;
18
+ evidence?: string;
19
+ activeTaskId?: string;
20
+ }
21
+
22
+ export type WorkflowTransitionResult =
23
+ | { applied: true; run: WorkflowRunState; terminal?: WorkflowTerminalStatus }
24
+ | { applied: false; error: string };
25
+
26
+ export function validateWorkflowDefinition(definition: WorkflowDefinition): string | undefined {
27
+ if (!definition || definition.version !== 1) return "Workflow version must be 1";
28
+ if (!definition.states || typeof definition.states !== "object") return "Workflow states must be an object";
29
+ if (!definition.initialState || !definition.states[definition.initialState]) {
30
+ return `Initial state "${definition.initialState}" is not defined`;
31
+ }
32
+
33
+ for (const [stateId, state] of Object.entries(definition.states)) {
34
+ if (!stateId) return "Workflow state IDs must be non-empty";
35
+ if (!state || typeof state !== "object") return `State "${stateId}" must be an object`;
36
+ if (typeof state.prompt !== "string") return `State "${stateId}" requires a prompt`;
37
+ if (!state.prompt.trim()) return `State "${stateId}" requires a prompt`;
38
+ if (state.on !== undefined && (typeof state.on !== "object" || Array.isArray(state.on))) {
39
+ return `State "${stateId}" transitions must be an object`;
40
+ }
41
+ if (state.terminal && state.on && Object.keys(state.on).length > 0) {
42
+ return `Terminal state "${stateId}" cannot declare transitions`;
43
+ }
44
+ if (state.maxAttempts !== undefined && (!Number.isInteger(state.maxAttempts) || state.maxAttempts < 1)) {
45
+ return `State "${stateId}" maxAttempts must be a positive integer`;
46
+ }
47
+ for (const [outcome, target] of Object.entries(state.on ?? {})) {
48
+ if (!outcome) return `State "${stateId}" has an empty outcome name`;
49
+ if (typeof target !== "string") return `Transition "${stateId}.${outcome}" target must be a state ID`;
50
+ if (!definition.states[target]) return `Transition "${stateId}.${outcome}" targets unknown state "${target}"`;
51
+ }
52
+ }
53
+
54
+ return undefined;
55
+ }
56
+
57
+ export function createWorkflowRun(definition: WorkflowDefinition, at: number): WorkflowRunState {
58
+ return {
59
+ definition,
60
+ currentState: definition.initialState,
61
+ transitionSeq: 0,
62
+ stateEnteredAt: at,
63
+ attemptsByState: { [definition.initialState]: 1 },
64
+ };
65
+ }
66
+
67
+ export function transitionWorkflowRun(
68
+ run: WorkflowRunState,
69
+ input: WorkflowTransitionInput,
70
+ at: number,
71
+ ): WorkflowTransitionResult {
72
+ const current = run.definition.states[run.currentState];
73
+ if (!current) return { applied: false, error: `Current state "${run.currentState}" is not defined` };
74
+ if (current.terminal) return { applied: false, error: `Workflow is already ${current.terminal}` };
75
+
76
+ const target = current.on?.[input.outcome];
77
+ if (!target) return { applied: false, error: `Outcome "${input.outcome}" is not allowed from state "${run.currentState}"` };
78
+
79
+ const targetState = run.definition.states[target];
80
+ if (!targetState) return { applied: false, error: `Transition target "${target}" is not defined` };
81
+
82
+ const nextAttempt = (run.attemptsByState[target] ?? 0) + 1;
83
+ if (targetState.maxAttempts !== undefined && nextAttempt > targetState.maxAttempts) {
84
+ return { applied: false, error: `State "${target}" has exhausted its ${targetState.maxAttempts} attempt limit` };
85
+ }
86
+
87
+ const sequence = run.transitionSeq + 1;
88
+ const lastTransition: WorkflowTransitionRecord = {
89
+ from: run.currentState,
90
+ to: target,
91
+ outcome: input.outcome,
92
+ evidence: input.evidence,
93
+ at,
94
+ sequence,
95
+ };
96
+ const nextRun: WorkflowRunState = {
97
+ ...run,
98
+ currentState: target,
99
+ transitionSeq: sequence,
100
+ stateEnteredAt: at,
101
+ attemptsByState: { ...run.attemptsByState, [target]: nextAttempt },
102
+ activeTaskId: input.activeTaskId,
103
+ lastTransition,
104
+ };
105
+
106
+ return { applied: true, run: nextRun, terminal: targetState.terminal };
107
+ }