@harms-haus/pi-workflows 1.0.0

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.
@@ -0,0 +1,258 @@
1
+ import type { WorkflowDefinition, PhaseDefinition, PhaseEntry } from "../types";
2
+ import { isSubworkflowRef } from "../types";
3
+
4
+ // ── Constants ──
5
+ export const VALID_COMMAND_NAME_RE = /^[a-zA-Z0-9_-]+$/;
6
+
7
+ // ── Validation helpers ──
8
+
9
+ function validateName(key: string, def: WorkflowDefinition): string | null {
10
+ if (!def.name || typeof def.name !== "string" || def.name.trim() === "") {
11
+ return `Workflow "${key}": name must be a non-empty string.`;
12
+ }
13
+ return null;
14
+ }
15
+
16
+ function validateUserFields(key: string, def: WorkflowDefinition): string | null {
17
+ if (!def.commandName || typeof def.commandName !== "string") {
18
+ return `Workflow "${key}": commandName must be a non-empty string.`;
19
+ }
20
+ if (!VALID_COMMAND_NAME_RE.test(def.commandName)) {
21
+ return `Workflow "${key}": commandName must match /^[a-zA-Z0-9_-]+$. Got: "${def.commandName}"`;
22
+ }
23
+ if (
24
+ !def.initialMessage ||
25
+ typeof def.initialMessage !== "string" ||
26
+ def.initialMessage.trim() === ""
27
+ ) {
28
+ return `Workflow "${key}": initialMessage must be a non-empty string.`;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function validatePhaseTools(key: string, phase: PhaseDefinition): string | null {
34
+ if (!phase.tools) return null;
35
+ if (phase.tools.blacklist && !Array.isArray(phase.tools.blacklist)) {
36
+ return `Workflow "${key}", phase "${phase.id}": blacklist must be an array.`;
37
+ }
38
+ if (phase.tools.whitelist && !Array.isArray(phase.tools.whitelist)) {
39
+ return `Workflow "${key}", phase "${phase.id}": whitelist must be an array.`;
40
+ }
41
+ if (phase.tools.blacklist && phase.tools.whitelist) {
42
+ return `Workflow "${key}", phase "${phase.id}": cannot set both blacklist and whitelist.`;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function validateConcretePhase(
48
+ key: string,
49
+ phase: PhaseDefinition,
50
+ index: number,
51
+ seenIds: Set<string>,
52
+ ): string | null {
53
+ if (!phase.id || typeof phase.id !== "string" || phase.id.trim() === "") {
54
+ return `Workflow "${key}", phase[${index}]: id must be a non-empty string.`;
55
+ }
56
+ if (!phase.name || typeof phase.name !== "string" || phase.name.trim() === "") {
57
+ return `Workflow "${key}", phase[${index}]: name must be a non-empty string.`;
58
+ }
59
+ if (!phase.emoji || typeof phase.emoji !== "string" || phase.emoji.trim() === "") {
60
+ return `Workflow "${key}", phase[${index}]: emoji must be a non-empty string.`;
61
+ }
62
+ if (
63
+ !phase.instructions ||
64
+ typeof phase.instructions !== "string" ||
65
+ phase.instructions.trim() === ""
66
+ ) {
67
+ return `Workflow "${key}", phase[${index}]: instructions must be a non-empty string.`;
68
+ }
69
+ if (seenIds.has(phase.id)) {
70
+ return `Workflow "${key}", phase[${index}]: duplicate phase id "${phase.id}".`;
71
+ }
72
+ seenIds.add(phase.id);
73
+ return validatePhaseTools(key, phase);
74
+ }
75
+
76
+ function validatePhaseEntry(
77
+ key: string,
78
+ phase: PhaseEntry,
79
+ index: number,
80
+ seenIds: Set<string>,
81
+ ): string | null {
82
+ if (isSubworkflowRef(phase)) {
83
+ if (
84
+ !phase.workflowKey ||
85
+ typeof phase.workflowKey !== "string" ||
86
+ phase.workflowKey.trim() === ""
87
+ ) {
88
+ return `Workflow "${key}", phase[${index}]: workflowKey must be a non-empty string.`;
89
+ }
90
+ return null;
91
+ }
92
+ return validateConcretePhase(key, phase, index, seenIds);
93
+ }
94
+
95
+ function validatePhases(key: string, def: WorkflowDefinition): string | null {
96
+ if (!Array.isArray(def.phases) || def.phases.length < 1) {
97
+ return `Workflow "${key}": phases must be an array with at least 1 element.`;
98
+ }
99
+ const seenIds = new Set<string>();
100
+ for (let i = 0; i < def.phases.length; i++) {
101
+ const phase = def.phases[i];
102
+ if (!phase) continue;
103
+ const err = validatePhaseEntry(key, phase, i, seenIds);
104
+ if (err) return err;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // ── Validation ──
110
+
111
+ /**
112
+ * Validates a workflow definition.
113
+ * Returns null if valid, or an error message string if invalid.
114
+ */
115
+ export function validateWorkflowDefinition(key: string, def: WorkflowDefinition): string | null {
116
+ const show = def.show ?? "user";
117
+
118
+ let err = validateName(key, def);
119
+ if (err) return err;
120
+
121
+ if (show === "user") {
122
+ err = validateUserFields(key, def);
123
+ if (err) return err;
124
+ }
125
+
126
+ err = validatePhases(key, def);
127
+ if (err) return err;
128
+
129
+ if (def.loopable !== undefined && typeof def.loopable !== "boolean") {
130
+ return `Workflow "${key}" has invalid loopable: must be a boolean`;
131
+ }
132
+
133
+ // Runtime check for show (type may be wider at runtime due to YAML parsing)
134
+ const showValue = def.show as string | undefined;
135
+ if (showValue !== undefined && showValue !== "user" && showValue !== "workflows") {
136
+ return `Workflow "${key}" has invalid show: must be "user" or "workflows"`;
137
+ }
138
+
139
+ return null;
140
+ }
141
+
142
+ // ── Cycle Detection ──
143
+
144
+ /** Reconstruct the cycle path from a back edge. */
145
+ function reconstructCycle(
146
+ startKey: string,
147
+ neighbor: string,
148
+ parent: Map<string, string>,
149
+ ): string[] {
150
+ const cycleKeys: string[] = [startKey];
151
+ let cur: string = startKey;
152
+ while (cur !== neighbor) {
153
+ const p = parent.get(cur);
154
+ if (p === undefined) break;
155
+ cur = p;
156
+ cycleKeys.push(cur);
157
+ }
158
+ cycleKeys.reverse();
159
+ return cycleKeys;
160
+ }
161
+
162
+ /** Build an adjacency list from the workflow definitions. */
163
+ function buildAdjacencyList(
164
+ definitions: Record<string, WorkflowDefinition>,
165
+ ): Map<string, string[]> {
166
+ const adj = new Map<string, string[]>();
167
+ for (const key of Object.keys(definitions)) {
168
+ const neighbors: string[] = [];
169
+ const def = definitions[key];
170
+ if (!def) continue;
171
+ for (const phase of def.phases) {
172
+ if (isSubworkflowRef(phase) && phase.workflowKey in definitions) {
173
+ neighbors.push(phase.workflowKey);
174
+ }
175
+ }
176
+ adj.set(key, neighbors);
177
+ }
178
+ return adj;
179
+ }
180
+
181
+ /** Process a single neighbor in the DFS — returns true if a cycle was found. */
182
+ function processNeighbor(
183
+ neighbor: string,
184
+ topKey: string,
185
+ parent: Map<string, string>,
186
+ color: Map<string, number>,
187
+ stack: Array<{ key: string; neighborIdx: number; phase: "enter" | "exit" }>,
188
+ errors: string[],
189
+ ): void {
190
+ const WHITE = 0,
191
+ GRAY = 1;
192
+ const neighborColor = color.get(neighbor) ?? WHITE;
193
+
194
+ if (neighborColor === GRAY) {
195
+ parent.set(neighbor, topKey);
196
+ const cycleKeys = reconstructCycle(topKey, neighbor, parent);
197
+ errors.push(
198
+ `Cycle detected: ${cycleKeys.join(" → ")} → ${cycleKeys[0]}. Skipping workflow "${cycleKeys[0]}".`,
199
+ );
200
+ } else if (neighborColor === WHITE) {
201
+ parent.set(neighbor, topKey);
202
+ stack.push({ key: neighbor, neighborIdx: 0, phase: "enter" });
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Validates that the subworkflow reference graph is a DAG (no cycles).
208
+ * Uses iterative DFS with 3-state coloring (WHITE/GRAY/BLACK).
209
+ * Returns an array of error messages (empty = no cycles).
210
+ */
211
+ export function detectCycles(definitions: Record<string, WorkflowDefinition>): string[] {
212
+ const errors: string[] = [];
213
+ const keys = Object.keys(definitions);
214
+ const adj = buildAdjacencyList(definitions);
215
+
216
+ const WHITE = 0,
217
+ GRAY = 1,
218
+ BLACK = 2;
219
+ const color = new Map<string, number>();
220
+ for (const key of keys) {
221
+ color.set(key, WHITE);
222
+ }
223
+
224
+ for (const startKey of keys) {
225
+ if (color.get(startKey) !== WHITE) continue;
226
+
227
+ const parent = new Map<string, string>();
228
+ const stack: Array<{ key: string; neighborIdx: number; phase: "enter" | "exit" }> = [
229
+ { key: startKey, neighborIdx: 0, phase: "enter" },
230
+ ];
231
+
232
+ while (stack.length > 0) {
233
+ const top = stack[stack.length - 1];
234
+ if (top === undefined) break;
235
+
236
+ if (top.phase === "enter") {
237
+ color.set(top.key, GRAY);
238
+ top.phase = "exit";
239
+ top.neighborIdx = 0;
240
+ continue;
241
+ }
242
+
243
+ const neighbors = adj.get(top.key) ?? [];
244
+ if (top.neighborIdx < neighbors.length) {
245
+ const neighbor = neighbors[top.neighborIdx];
246
+ if (!neighbor) continue;
247
+ top.neighborIdx++;
248
+ processNeighbor(neighbor, top.key, parent, color, stack, errors);
249
+ continue;
250
+ }
251
+
252
+ color.set(top.key, BLACK);
253
+ stack.pop();
254
+ }
255
+ }
256
+
257
+ return errors;
258
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,265 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ToolCallEvent,
5
+ AgentEndEvent,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { WorkflowState, WorkflowDefinition, HookStateMutation } from "./types";
8
+ import { isSubworkflowRef } from "./types";
9
+ import { resolveActive, isActive, phaseEntryName } from "./state";
10
+ import {
11
+ buildContextPrompt,
12
+ DEFAULT_NOT_DONE_REMINDER,
13
+ DEFAULT_CANCELLED_MESSAGE,
14
+ DEFAULT_COMPLETION_MESSAGE,
15
+ } from "./prompts";
16
+ import { resolveTemplate, getBlockedTools, getWhitelist } from "./config";
17
+ import { timerManager } from "./TimerManager";
18
+
19
+ // ── Status Bar ──
20
+ export function updateStatus(
21
+ ctx: { ui: { setStatus: (key: string, text: string | undefined) => void } },
22
+ state: WorkflowState | null,
23
+ definitions: Record<string, WorkflowDefinition>,
24
+ ): void {
25
+ if (
26
+ !state ||
27
+ !state.active ||
28
+ state.currentPath.length === 0 ||
29
+ !state.currentPath[0] ||
30
+ !(state.currentPath[0].workflowKey in definitions)
31
+ ) {
32
+ ctx.ui.setStatus("workflow", undefined);
33
+ return;
34
+ }
35
+ const parts: string[] = [];
36
+
37
+ // First part: top-level workflow name only (no progress)
38
+ const topDef = definitions[state.currentPath[0].workflowKey];
39
+ if (!topDef) return;
40
+ parts.push(topDef.name);
41
+
42
+ // For each path segment, show progress at that level
43
+ for (let i = 0; i < state.currentPath.length; i++) {
44
+ const seg = state.currentPath[i];
45
+ const segDef = seg ? definitions[seg.workflowKey] : undefined;
46
+ if (!seg || !segDef) {
47
+ ctx.ui.setStatus("workflow", undefined);
48
+ return;
49
+ }
50
+ const entry = seg.phaseIndex < segDef.phases.length ? segDef.phases[seg.phaseIndex] : null;
51
+ if (!entry) {
52
+ ctx.ui.setStatus("workflow", undefined);
53
+ return;
54
+ }
55
+ const current = seg.phaseIndex + 1;
56
+ const total = segDef.phases.length;
57
+
58
+ if (isSubworkflowRef(entry)) {
59
+ parts.push(`${phaseEntryName(entry)} [${current}/${total}]`);
60
+ } else {
61
+ parts.push(`${entry.emoji} ${entry.name} [${current}/${total}]`);
62
+ }
63
+ }
64
+
65
+ const statusText = parts.join(" > ");
66
+ ctx.ui.setStatus("workflow", statusText);
67
+ }
68
+
69
+ // ── tool_call Hook ──
70
+ export function handleToolCall(
71
+ event: ToolCallEvent,
72
+ state: WorkflowState | null,
73
+ definitions: Record<string, WorkflowDefinition>,
74
+ ): { block: true; reason: string } | undefined {
75
+ if (!isActive(state)) return;
76
+ const active = resolveActive(state, definitions);
77
+ if (!active) return;
78
+ const toolName = event.toolName;
79
+ // Always allow workflow_step
80
+ if (toolName === "workflow_step") return;
81
+ const phase = active.currentPhase;
82
+ const toolConfig = phase.tools;
83
+ if (!toolConfig) return; // No tool restrictions for this phase
84
+ const definition = active.definition;
85
+ const blockedTools = getBlockedTools(phase);
86
+ const whitelist = getWhitelist(phase);
87
+ if (toolConfig.blacklist && blockedTools.includes(toolName)) {
88
+ return {
89
+ block: true,
90
+ reason: resolveTemplate(definition.blockReasonTemplate ?? DEFAULT_BLOCK_REASON, {
91
+ workflowName: definition.name,
92
+ phaseName: phase.name,
93
+ toolName,
94
+ allowedTools: "all except: " + blockedTools.join(", "),
95
+ }),
96
+ };
97
+ }
98
+ if (toolConfig.whitelist && whitelist && !whitelist.includes(toolName)) {
99
+ return {
100
+ block: true,
101
+ reason: resolveTemplate(definition.blockReasonTemplate ?? DEFAULT_BLOCK_REASON, {
102
+ workflowName: definition.name,
103
+ phaseName: phase.name,
104
+ toolName,
105
+ allowedTools: whitelist.join(", "),
106
+ }),
107
+ };
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ const DEFAULT_BLOCK_REASON =
113
+ `[workflow] The tool "{toolName}" is blocked during the {phaseName} phase.\n` +
114
+ `Refer to the current phase instructions for allowed tools and approaches.\n` +
115
+ `When finished, call workflow_step to advance to the next phase.`;
116
+
117
+ // ── before_agent_start Hook ──
118
+ export function handleBeforeAgentStart(
119
+ state: WorkflowState | null,
120
+ definitions: Record<string, WorkflowDefinition>,
121
+ ): { message: { customType: string; content: string; display: boolean } } | undefined {
122
+ if (!isActive(state)) return;
123
+ const active = resolveActive(state, definitions);
124
+ if (!active) return;
125
+ const prompt = buildContextPrompt(active);
126
+ return { message: { customType: "workflow:context", content: prompt, display: false } };
127
+ }
128
+
129
+ // ── agent_end Hook ──
130
+
131
+ /**
132
+ * Check if the last assistant message in the agent_end event was aborted
133
+ * (i.e., the user interrupted the agent). Returns true if the agent was
134
+ * interrupted, false if it stopped naturally.
135
+ */
136
+ function wasAborted(messages: AgentEndEvent["messages"]): boolean {
137
+ // Walk messages in reverse to find the last assistant message
138
+ for (let i = messages.length - 1; i >= 0; i--) {
139
+ const msg = messages[i];
140
+ if (!msg) continue;
141
+ if (msg.role === "assistant") {
142
+ return (msg as { stopReason?: string }).stopReason === "aborted";
143
+ }
144
+ }
145
+ // No assistant message found — shouldn't happen, but treat as not aborted
146
+ return false;
147
+ }
148
+
149
+ /** Send a completion/cancellation message for the workflow. */
150
+ function sendCompletionMessage(
151
+ pi: ExtensionAPI,
152
+ definition: WorkflowDefinition,
153
+ state: WorkflowState,
154
+ template: string,
155
+ ): void {
156
+ const msg = resolveTemplate(template, {
157
+ workflowName: definition.name,
158
+ taskDescription: state.taskDescription,
159
+ taskId: state.taskId,
160
+ phaseCount: String(definition.phases.length),
161
+ });
162
+ pi.sendMessage(
163
+ { customType: "workflow:complete", content: msg, display: true },
164
+ { triggerTurn: false },
165
+ );
166
+ }
167
+
168
+ /** Start a countdown widget that auto-continues after a delay. */
169
+ function startCountdown(pi: ExtensionAPI, ctx: ExtensionContext, reminder: string): void {
170
+ if (ctx.hasUI) {
171
+ // Capture hasUI flag to guard against stale ctx in callbacks
172
+ const ui = ctx.ui;
173
+
174
+ let remaining = 3;
175
+ timerManager.startInterval(1000, () => {
176
+ try {
177
+ remaining--;
178
+ if (remaining > 0) {
179
+ ui.setWidget(
180
+ "workflow-countdown",
181
+ [`⏳ Auto-continuing in ${remaining}s... (type anything to interrupt)`],
182
+ { placement: "aboveEditor" },
183
+ );
184
+ } else {
185
+ timerManager.clearAll();
186
+ ui.setWidget("workflow-countdown", undefined);
187
+ try {
188
+ pi.sendUserMessage(reminder);
189
+ } catch {
190
+ // User already started typing — skip auto-continue
191
+ }
192
+ }
193
+ } catch {
194
+ timerManager.clearAll();
195
+ ui.setWidget("workflow-countdown", undefined);
196
+ }
197
+ });
198
+
199
+ ui.setWidget(
200
+ "workflow-countdown",
201
+ ["⏳ Auto-continuing in 3s... (type anything to interrupt)"],
202
+ { placement: "aboveEditor" },
203
+ );
204
+ } else {
205
+ pi.sendMessage(
206
+ {
207
+ customType: "workflow:countdown",
208
+ content: "Auto-continuing workflow in 3s... (type anything to interrupt)",
209
+ display: true,
210
+ },
211
+ { triggerTurn: false },
212
+ );
213
+ timerManager.startTimeout(3000, () => {
214
+ try {
215
+ pi.sendUserMessage(reminder);
216
+ } catch {
217
+ // User already started typing — skip auto-continue
218
+ }
219
+ });
220
+ }
221
+ }
222
+
223
+ export function handleAgentEnd(
224
+ pi: ExtensionAPI,
225
+ state: WorkflowState | null,
226
+ definitions: Record<string, WorkflowDefinition>,
227
+ ctx: ExtensionContext,
228
+ event: AgentEndEvent,
229
+ ): HookStateMutation {
230
+ const noOp: HookStateMutation = { unload: false, persist: false };
231
+ if (!state) return noOp;
232
+ // Case A: Workflow just reached DONE (not yet notified)
233
+ if (!state.active && !state.completionNotified) {
234
+ const definition = definitions[state.workflowKey];
235
+ if (!definition) return noOp;
236
+ const template = state.cancelled
237
+ ? (definition.completionMessage ?? DEFAULT_CANCELLED_MESSAGE)
238
+ : (definition.completionMessage ?? DEFAULT_COMPLETION_MESSAGE);
239
+ sendCompletionMessage(pi, definition, state, template);
240
+ const mutatedState = { ...state, completionNotified: true };
241
+ ctx.ui.setStatus("workflow", undefined);
242
+ return { unload: true, persist: true, state: mutatedState };
243
+ }
244
+ // Case B: Workflow is still active (agent tried to stop mid-workflow)
245
+ if (state.active) {
246
+ if (wasAborted(event.messages)) {
247
+ return noOp;
248
+ }
249
+ const active = resolveActive(state, definitions);
250
+ if (!active) return noOp;
251
+ const { definition, currentPhase } = active;
252
+ const reminder = resolveTemplate(definition.notDoneReminder ?? DEFAULT_NOT_DONE_REMINDER, {
253
+ workflowName: definition.name,
254
+ phaseName: currentPhase.name,
255
+ phaseEmoji: currentPhase.emoji,
256
+ phaseInstructions: currentPhase.instructions,
257
+ taskDescription: state.taskDescription,
258
+ taskId: state.taskId,
259
+ workflowKey: state.workflowKey,
260
+ });
261
+ startCountdown(pi, ctx, reminder);
262
+ return noOp;
263
+ }
264
+ return noOp;
265
+ }
package/src/index.ts ADDED
@@ -0,0 +1,98 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { WorkflowState, WorkflowDefinition } from "./types";
3
+ import { loadWorkflows } from "./config";
4
+ import { persistState, reconstructState } from "./state";
5
+ import { updateStatus, handleToolCall, handleBeforeAgentStart, handleAgentEnd } from "./hooks";
6
+ import { timerManager } from "./TimerManager";
7
+ import { registerWorkflowTool } from "./tool";
8
+ import { registerWorkflowCommand, registerCancelWorkflowCommand } from "./command";
9
+ import { registerRenderers } from "./renderers";
10
+
11
+ /** Check if an error is a stale-context error (session was replaced/reloaded mid-handler). */
12
+ function isStaleError(e: unknown): boolean {
13
+ return e instanceof Error && e.message.includes("stale");
14
+ }
15
+
16
+ /** Wrap a synchronous handler so stale-context errors are silently swallowed. */
17
+ function withStaleGuard(fn: () => void): void {
18
+ try {
19
+ fn();
20
+ } catch (e) {
21
+ if (isStaleError(e)) return;
22
+ throw e;
23
+ }
24
+ }
25
+
26
+ export default function (pi: ExtensionAPI): void {
27
+ let state: WorkflowState | null = null;
28
+ let definitions: Record<string, WorkflowDefinition> = {};
29
+
30
+ const getState = () => state;
31
+ const setState = (s: WorkflowState | null) => {
32
+ state = s;
33
+ };
34
+ const getDefinitions = () => definitions;
35
+ const reloadDefinitions = (cwd?: string) => {
36
+ definitions = loadWorkflows(cwd);
37
+ return Promise.resolve(definitions);
38
+ };
39
+
40
+ /** Shared session initialisation used by session_start and session_tree. */
41
+ function initSession(
42
+ ctx: Parameters<typeof reconstructState>[0] &
43
+ Parameters<typeof updateStatus>[0] & { cwd: string },
44
+ ) {
45
+ timerManager.clearAll();
46
+ definitions = loadWorkflows(ctx.cwd);
47
+ state = reconstructState(ctx);
48
+ updateStatus(ctx, state, definitions);
49
+ }
50
+
51
+ pi.on("session_start", (_event, ctx) => {
52
+ withStaleGuard(() => {
53
+ initSession(ctx);
54
+ });
55
+ });
56
+
57
+ pi.on("session_tree", (_event, ctx) => {
58
+ withStaleGuard(() => {
59
+ initSession(ctx);
60
+ });
61
+ });
62
+
63
+ pi.on("tool_call", (event, _ctx) => {
64
+ return handleToolCall(event, state, definitions);
65
+ });
66
+
67
+ pi.on("before_agent_start", (_event, _ctx) => {
68
+ return handleBeforeAgentStart(state, definitions);
69
+ });
70
+
71
+ pi.on("agent_end", (event, ctx) => {
72
+ withStaleGuard(() => {
73
+ const mutation = handleAgentEnd(pi, state, definitions, ctx, event);
74
+ if (mutation.unload) {
75
+ if (mutation.persist && state) {
76
+ persistState(pi, state);
77
+ }
78
+ state = null;
79
+ } else if (mutation.state) {
80
+ state = mutation.state;
81
+ if (mutation.persist) {
82
+ persistState(pi, state);
83
+ }
84
+ }
85
+ });
86
+ });
87
+
88
+ pi.on("turn_end", (_event, ctx) => {
89
+ withStaleGuard(() => {
90
+ updateStatus(ctx, state, definitions);
91
+ });
92
+ });
93
+
94
+ registerWorkflowTool(pi, getState, getDefinitions, setState);
95
+ registerWorkflowCommand(pi, getState, reloadDefinitions, setState);
96
+ registerCancelWorkflowCommand(pi, getState, setState);
97
+ registerRenderers(pi);
98
+ }