@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
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { LoopEntry, Trigger } from "../types.js";
2
+ import type { LoopEntry, Trigger, WorkflowDefinition } from "../types.js";
3
3
  interface LoopStoreLike {
4
4
  list(): LoopEntry[];
5
5
  get(id: string): LoopEntry | undefined;
@@ -9,12 +9,25 @@ interface LoopStoreLike {
9
9
  taskBacklog?: boolean;
10
10
  readOnly?: boolean;
11
11
  maxFires?: number;
12
+ dynamic?: Partial<NonNullable<LoopEntry["dynamic"]>>;
13
+ workflow?: WorkflowDefinition;
12
14
  }): LoopEntry;
13
15
  pause(id: string): LoopEntry | undefined;
14
16
  updateDynamic(id: string, fields: {
15
17
  prompt?: string;
16
18
  dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
17
19
  }): LoopEntry | undefined;
20
+ transitionWorkflow(id: string, input: {
21
+ outcome: string;
22
+ evidence?: string;
23
+ activeTaskId?: string;
24
+ }): {
25
+ entry?: LoopEntry;
26
+ applied: boolean;
27
+ error?: string;
28
+ terminal?: "completed" | "paused";
29
+ };
30
+ setWorkflowActiveTask(id: string, taskId?: string): LoopEntry | undefined;
18
31
  getDeletionTombstone(id: string): {
19
32
  reason: string;
20
33
  pendingCount?: number;
@@ -44,6 +57,9 @@ export interface LoopToolsOptions {
44
57
  updateWidget: () => void;
45
58
  maybeBootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
46
59
  isTaskSystemReady: () => boolean;
60
+ onDynamicLoopActivated?: (entry: LoopEntry) => void;
61
+ createWorkflowTask?: (entry: LoopEntry) => Promise<string | undefined>;
62
+ completeWorkflowTask?: (taskId: string) => Promise<boolean>;
47
63
  }
48
64
  export declare function registerLoopTools(options: LoopToolsOptions): void;
49
65
  export {};
@@ -1,7 +1,9 @@
1
1
  import { Type } from "typebox";
2
2
  import { formatTrigger } from "../loop-format.js";
3
3
  import { parseInterval } from "../loop-parse.js";
4
- import { textResult } from "./tool-result.js";
4
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
5
+ import { validateWorkflowDefinition } from "../workflow-reducer.js";
6
+ import { displayRows, textResult } from "./tool-result.js";
5
7
  function validateTrigger(trigger) {
6
8
  if (trigger.type === "cron") {
7
9
  const parts = trigger.schedule.trim().split(/\s+/);
@@ -58,6 +60,54 @@ function resolveNextWakeAt(nextInterval) {
58
60
  return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
59
61
  return { nextWakeAt: Date.now() + parsedDelayMs };
60
62
  }
63
+ function parseWorkflowDefinition(input) {
64
+ try {
65
+ const parsed = JSON.parse(input);
66
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67
+ return { error: "Workflow definition must be a JSON object" };
68
+ }
69
+ const definition = parsed;
70
+ const validationError = validateWorkflowDefinition(definition);
71
+ return validationError ? { error: validationError } : { definition };
72
+ }
73
+ catch {
74
+ return { error: "Workflow definition must be valid JSON" };
75
+ }
76
+ }
77
+ const WORKFLOW_DEFINITION_EXAMPLE = '{"version":1,"initialState":"investigate","states":{"investigate":{"prompt":"Investigate the issue.","on":{"found":"done"}},"done":{"prompt":"Report completion.","terminal":"completed"}}}';
78
+ function formatWorkflowDefinitionError(error) {
79
+ return `Workflow definition rejected: ${error ?? "unknown validation error"}\n` +
80
+ "Required fields: version: 1, initialState, and states.\n" +
81
+ `Example definition:\n${WORKFLOW_DEFINITION_EXAMPLE}\n` +
82
+ "Next: correct the JSON and call WorkflowCreate again.";
83
+ }
84
+ function formatWorkflowSummary(entry, heading) {
85
+ const workflow = entry.workflow;
86
+ const state = workflow.definition.states[workflow.currentState];
87
+ const outcomes = Object.keys(state?.on ?? {});
88
+ let message = `${heading}\nGoal: ${entry.prompt}\nCurrent state: ${workflow.currentState}`;
89
+ if (state?.prompt)
90
+ message += `\nInstruction: ${state.prompt}`;
91
+ if (workflow.activeTaskId) {
92
+ message += `\nActive task: #${workflow.activeTaskId}`;
93
+ }
94
+ else if (state?.task) {
95
+ message += "\nTask: no task was created for this state";
96
+ }
97
+ else {
98
+ message += "\nTask: none configured for this state";
99
+ }
100
+ if (state?.terminal) {
101
+ message += `\nTerminal: ${state.terminal}`;
102
+ return message;
103
+ }
104
+ if (outcomes.length === 0) {
105
+ return `${message}\nNeeds attention: this state has no declared outcomes, so it cannot advance.`;
106
+ }
107
+ message += `\nChoose outcome: ${outcomes.join(", ")}`;
108
+ message += `\nNext: WorkflowTransition({ id: "${entry.id}", outcome: "${outcomes[0]}", evidence: "..." })`;
109
+ return message;
110
+ }
61
111
  function formatDynamicUpdateResult(id, iteration, nextWakeAt) {
62
112
  const mode = nextWakeAt === undefined
63
113
  ? "Next wake: when idle"
@@ -103,10 +153,12 @@ function stopDynamicLoop(params, store, triggerSystem) {
103
153
  return `Dynamic loop #${params.id} paused`;
104
154
  }
105
155
  export function registerLoopTools(options) {
106
- const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, } = options;
156
+ const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, onDynamicLoopActivated, createWorkflowTask, completeWorkflowTask, } = options;
107
157
  pi.registerTool({
108
158
  name: "LoopCreate",
109
159
  label: "LoopCreate",
160
+ renderCall: renderToolCall("Loop", (args) => `create · ${String(toolArg(args, "prompt") ?? "scheduled work").slice(0, 56)}`),
161
+ renderResult: renderToolResult,
110
162
  description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
111
163
 
112
164
  Use this tool whenever the user asks to:
@@ -138,7 +190,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
138
190
  - **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
139
191
  - **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
140
192
  - **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
141
- - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops`,
193
+ - **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
194
+
195
+ ## Loop Lifecycle
196
+
197
+ Recurring loops persist across fires. A completed iteration, unchanged result, or temporarily empty check is not a reason to delete the loop. Delete only when the user explicitly cancels it or its stated stop condition is satisfied. Dynamic loops must be advanced with LoopUpdate, not LoopDelete.`,
142
198
  promptGuidelines: [
143
199
  "Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
144
200
  "## Choosing trigger type",
@@ -150,14 +206,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
150
206
  "Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
151
207
  "## maxFires — prevent infinite token burn",
152
208
  "Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
153
- "When a loop fires and finds nothing to do, call LoopDelete on its own ID to stop it don't keep polling.",
209
+ "Recurring loops are persistent controllers. Do not call LoopDelete after a normal fire, an unchanged check, or one completed iteration; only delete when the user explicitly asks to cancel or the loop's stated stop condition is satisfied.",
154
210
  "## readOnly mode",
155
211
  "Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
156
212
  "## Task-driven workflows",
157
213
  "Do not rely on a past 'tasks:created' event to replay. If tasks already exist, bootstrap the first pass in the current turn or use a hybrid/event loop that can catch future task creation and a cron safety-net.",
158
214
  "Use autoTask only when you want the loop itself to create a task on each fire. For processing an existing task backlog, leave autoTask off and have the loop run TaskList to pick the next pending task.",
159
215
  "Set taskBacklog: true for backlog worker loops that process the existing pending queue. Backlog worker loops bootstrap against existing pending tasks and auto-delete when the queue reaches zero.",
160
- "When no tasks are pending, the loop should stop itself or skip the wake entirely no tokens burned on empty polls.",
216
+ "For taskBacklog loops, do not instruct the agent to delete the loop; pi-loop auto-deletes it when the pending count reaches zero.",
161
217
  "After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
162
218
  ],
163
219
  parameters: Type.Object({
@@ -194,8 +250,15 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
194
250
  };
195
251
  }
196
252
  const validationError = validateTrigger(trigger);
197
- if (validationError)
198
- return Promise.resolve(textResult(validationError));
253
+ if (validationError) {
254
+ return Promise.resolve(textResult(validationError, {
255
+ kind: "loop",
256
+ action: "create",
257
+ tone: "error",
258
+ summary: "Loop was not created",
259
+ expanded: [validationError],
260
+ }));
261
+ }
199
262
  const entry = getStore().create(trigger, prompt, {
200
263
  recurring: recurring ?? (inferred !== "event"),
201
264
  autoTask,
@@ -230,20 +293,214 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
230
293
  (entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
231
294
  (bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
232
295
  (isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
233
- `ID: ${entry.id} (use LoopDelete to cancel)`));
296
+ `ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)`, {
297
+ kind: "loop",
298
+ action: "create",
299
+ tone: "success",
300
+ summary: `Loop #${entry.id} active · ${triggerDesc}`,
301
+ expanded: [
302
+ `Goal: ${entry.prompt}`,
303
+ `Trigger: ${triggerDesc}`,
304
+ entry.autoTask ? "Auto-task: enabled" : "Auto-task: off",
305
+ ],
306
+ }));
307
+ },
308
+ });
309
+ pi.registerTool({
310
+ name: "WorkflowCreate",
311
+ label: "WorkflowCreate",
312
+ renderCall: renderToolCall("Workflow", (args) => `create · ${String(toolArg(args, "goal") ?? "workflow").slice(0, 56)}`),
313
+ renderResult: renderToolResult,
314
+ description: `Create an opt-in task-driven workflow loop from a JSON state definition.
315
+
316
+ Use this when work has named phases and explicit outcomes, such as investigate → fix → validate. Use LoopCreate for ordinary scheduled/event work and TaskCreate for a normal flat backlog.
317
+
318
+ The definition requires version: 1, initialState, and states. Each state has a prompt, optional on outcome-to-state map, optional maxAttempts, and an optional terminal value of completed or paused.`,
319
+ promptGuidelines: [
320
+ "Use WorkflowCreate only for explicit multi-phase work with stable named outcomes; ordinary reminders, polling, and task backlogs should remain loops or tasks.",
321
+ "Pass `definition` as valid JSON. Give each non-terminal state a concise prompt and explicit outcome names, for example `root_cause_found` or `tests_pass`.",
322
+ "After each workflow wake, call WorkflowTransition with the workflow `id` and one declared `outcome`; include concise `evidence` whenever a branch is chosen.",
323
+ ],
324
+ parameters: Type.Object({
325
+ goal: Type.String({ description: "Overall workflow goal" }),
326
+ definition: Type.String({ description: "Workflow JSON: version, initialState, and named states" }),
327
+ maxFires: Type.Optional(Type.Number({ description: "Maximum workflow wakes before automatic expiry (default: 30)", default: 30 })),
328
+ }),
329
+ async execute(_toolCallId, params) {
330
+ const parsed = parseWorkflowDefinition(params.definition);
331
+ if (!parsed.definition) {
332
+ const message = formatWorkflowDefinitionError(parsed.error);
333
+ return textResult(message, {
334
+ kind: "workflow",
335
+ action: "create",
336
+ tone: "error",
337
+ summary: "Workflow definition rejected",
338
+ expanded: [parsed.error ?? "unknown validation error", "Expand the tool result for a valid definition skeleton."],
339
+ });
340
+ }
341
+ let entry = getStore().create({ type: "dynamic" }, params.goal, {
342
+ recurring: true,
343
+ maxFires: params.maxFires ?? 30,
344
+ dynamic: { goal: params.goal, state: parsed.definition.initialState, iteration: 0 },
345
+ workflow: parsed.definition,
346
+ });
347
+ const taskId = await createWorkflowTask?.(entry);
348
+ if (taskId)
349
+ entry = getStore().setWorkflowActiveTask(entry.id, taskId) ?? entry;
350
+ getTriggerSystem().add(entry);
351
+ updateWidget();
352
+ onDynamicLoopActivated?.(entry);
353
+ return textResult(`${formatWorkflowSummary(entry, `Workflow #${entry.id} created — ${entry.status}`)}\n` +
354
+ "Wake: the state instruction will be delivered when the agent becomes idle.", {
355
+ kind: "workflow",
356
+ action: "create",
357
+ tone: "success",
358
+ summary: `Workflow #${entry.id} active · ${parsed.definition.initialState}${taskId ? ` · task #${taskId}` : ""}`,
359
+ expanded: [
360
+ `Goal: ${entry.prompt}`,
361
+ `State: ${parsed.definition.initialState}`,
362
+ `Outcome: ${Object.keys(parsed.definition.states[parsed.definition.initialState]?.on ?? {}).join(", ") || "none"}`,
363
+ "Wake: delivered when the agent becomes idle",
364
+ ],
365
+ });
366
+ },
367
+ });
368
+ pi.registerTool({
369
+ name: "WorkflowTransition",
370
+ label: "WorkflowTransition",
371
+ renderCall: renderToolCall("Workflow", (args) => `transition · #${String(toolArg(args, "id") ?? "?")} → ${String(toolArg(args, "outcome") ?? "?")}`),
372
+ renderResult: renderToolResult,
373
+ description: `Advance an opt-in workflow using one declared outcome.
374
+
375
+ Use exactly once after completing the current workflow state. The outcome must be declared in the current state's on map. Include evidence for the branch decision. This tool validates the transition, records it, and queues the next state; it completes or pauses terminal workflows automatically.`,
376
+ promptGuidelines: [
377
+ "WorkflowTransition uses `id`, not `loopId`.",
378
+ "Use an exact outcome name declared by the current state. Do not invent an outcome; inspect the wake message or WorkflowList first.",
379
+ "Include `evidence` that justifies the transition, especially for completion, regression, or blocked outcomes.",
380
+ ],
381
+ parameters: Type.Object({
382
+ id: Type.String({ description: "Workflow loop ID" }),
383
+ outcome: Type.String({ description: "Declared outcome for the current workflow state" }),
384
+ evidence: Type.Optional(Type.String({ description: "Concise evidence supporting this transition" })),
385
+ activeTaskId: Type.Optional(Type.String({ description: "Optional task ID now active in the destination state" })),
386
+ }),
387
+ async execute(_toolCallId, params) {
388
+ const store = getStore();
389
+ const sourceTaskId = store.get(params.id)?.workflow?.activeTaskId;
390
+ const result = store.transitionWorkflow(params.id, {
391
+ outcome: params.outcome,
392
+ evidence: params.evidence,
393
+ activeTaskId: params.activeTaskId,
394
+ });
395
+ if (!result.applied || !result.entry) {
396
+ const current = store.get(params.id);
397
+ if (current?.workflow) {
398
+ return textResult(`Workflow #${params.id} did not transition\n` +
399
+ `Reason: ${result.error ?? "unknown transition error"}\n` +
400
+ formatWorkflowSummary(current, `Workflow #${params.id} remains — ${current.status}`), {
401
+ kind: "workflow",
402
+ action: "transition",
403
+ tone: "error",
404
+ summary: `Workflow #${params.id} remains in ${current.workflow.currentState}`,
405
+ expanded: [result.error ?? "unknown transition error"],
406
+ });
407
+ }
408
+ return textResult(result.error ?? `Workflow loop #${params.id} did not transition`);
409
+ }
410
+ const entry = result.entry;
411
+ getTriggerSystem().remove(entry.id);
412
+ const sourceTaskClosed = sourceTaskId ? await completeWorkflowTask?.(sourceTaskId) : undefined;
413
+ if (result.terminal === "completed") {
414
+ store.delete(entry.id);
415
+ updateWidget();
416
+ return textResult(`Workflow #${entry.id} completed and deleted\n` +
417
+ `Final transition: ${entry.workflow?.lastTransition?.from ?? "?"} → ${entry.workflow?.currentState ?? "?"}\n` +
418
+ "Next: no further workflow transition is needed.", {
419
+ kind: "workflow",
420
+ action: "transition",
421
+ tone: "success",
422
+ summary: `Workflow #${entry.id} completed${sourceTaskClosed ? ` · task #${sourceTaskId} closed` : ""}`,
423
+ expanded: [
424
+ `Final transition: ${entry.workflow?.lastTransition?.from ?? "?"} → ${entry.workflow?.currentState ?? "?"}`,
425
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
426
+ ],
427
+ });
428
+ }
429
+ if (result.terminal === "paused") {
430
+ store.pause(entry.id);
431
+ updateWidget();
432
+ return textResult(`Workflow #${entry.id} paused\n` +
433
+ `Final state: ${entry.workflow?.currentState ?? "?"}\n` +
434
+ "Next: inspect it with WorkflowList before deciding whether to resume or delete it.", {
435
+ kind: "workflow",
436
+ action: "transition",
437
+ tone: "warning",
438
+ summary: `Workflow #${entry.id} paused · ${entry.workflow?.currentState ?? "?"}`,
439
+ expanded: [
440
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
441
+ "Inspect WorkflowList before resuming or deleting this workflow.",
442
+ ],
443
+ });
444
+ }
445
+ const taskId = await createWorkflowTask?.(entry);
446
+ const updatedEntry = taskId ? store.setWorkflowActiveTask(entry.id, taskId) ?? entry : entry;
447
+ getTriggerSystem().add(updatedEntry);
448
+ updateWidget();
449
+ const from = updatedEntry.workflow?.lastTransition?.from ?? "?";
450
+ const to = updatedEntry.workflow?.currentState ?? "?";
451
+ return textResult(`Workflow #${updatedEntry.id} advanced: ${from} → ${to}\n` +
452
+ formatWorkflowSummary(updatedEntry, `Workflow #${updatedEntry.id} — ${updatedEntry.status}`), {
453
+ kind: "workflow",
454
+ action: "transition",
455
+ tone: "success",
456
+ summary: `Workflow #${updatedEntry.id} · ${from} → ${to}${taskId ? ` · task #${taskId}` : ""}`,
457
+ expanded: [
458
+ `Instruction: ${updatedEntry.workflow?.definition.states[to]?.prompt ?? ""}`,
459
+ `Outcome: ${Object.keys(updatedEntry.workflow?.definition.states[to]?.on ?? {}).join(", ") || "none"}`,
460
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
461
+ ],
462
+ });
463
+ },
464
+ });
465
+ pi.registerTool({
466
+ name: "WorkflowList",
467
+ label: "WorkflowList",
468
+ renderCall: renderToolCall("Workflow", () => "status"),
469
+ renderResult: renderToolResult,
470
+ description: "List opt-in workflow loops with their current state, active task, and declared outcomes. Use this before WorkflowTransition when the current state is unclear.",
471
+ parameters: Type.Object({}),
472
+ execute() {
473
+ const workflows = getStore().list().filter((entry) => entry.workflow);
474
+ if (workflows.length === 0) {
475
+ return Promise.resolve(textResult("No workflow loops configured.\n" +
476
+ "Next: use WorkflowCreate for explicit state-and-outcome work, or LoopCreate for an ordinary schedule.", { kind: "workflow", action: "list", tone: "info", summary: "No workflows", expanded: ["Use WorkflowCreate for state-and-outcome work."] }));
477
+ }
478
+ const lines = workflows.map((entry) => formatWorkflowSummary(entry, `Workflow #${entry.id} — ${entry.status}`));
479
+ return Promise.resolve(textResult(`${workflows.length} workflow${workflows.length === 1 ? "" : "s"} configured\n\n${lines.join("\n\n")}`, {
480
+ kind: "workflow",
481
+ action: "list",
482
+ tone: "info",
483
+ summary: `${workflows.length} workflow${workflows.length === 1 ? "" : "s"} · ${workflows.filter((entry) => entry.status === "active").length} active`,
484
+ expanded: displayRows(workflows.map((entry) => `#${entry.id} · ${entry.status} · ${entry.workflow?.currentState ?? "?"} · ${entry.prompt}`)),
485
+ }));
234
486
  },
235
487
  });
236
488
  pi.registerTool({
237
489
  name: "LoopList",
238
490
  label: "LoopList",
491
+ renderCall: renderToolCall("Loop", () => "status"),
492
+ renderResult: renderToolResult,
239
493
  description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
240
494
 
241
495
  Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
242
496
  parameters: Type.Object({}),
243
497
  execute() {
244
498
  const loops = getStore().list();
245
- if (loops.length === 0)
246
- return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
499
+ if (loops.length === 0) {
500
+ return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule.", {
501
+ kind: "loop", action: "list", tone: "info", summary: "No loops", expanded: ["Use LoopCreate to set up a schedule."],
502
+ }));
503
+ }
247
504
  const lines = [];
248
505
  for (const entry of loops) {
249
506
  const triggerDesc = formatTrigger(entry.trigger, "list");
@@ -259,17 +516,27 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
259
516
  line += " [auto-task]";
260
517
  if (entry.taskBacklog)
261
518
  line += " [backlog-worker]";
519
+ if (entry.workflow)
520
+ line += ` [workflow:${entry.workflow.currentState}]`;
262
521
  lines.push(line);
263
522
  }
264
- return Promise.resolve(textResult(lines.join("\n")));
523
+ return Promise.resolve(textResult(lines.join("\n"), {
524
+ kind: "loop",
525
+ action: "list",
526
+ tone: "info",
527
+ summary: `${loops.length} loop${loops.length === 1 ? "" : "s"} · ${loops.filter((entry) => entry.status === "active").length} active`,
528
+ expanded: displayRows(lines),
529
+ }));
265
530
  },
266
531
  });
267
532
  pi.registerTool({
268
533
  name: "LoopUpdate",
269
534
  label: "LoopUpdate",
535
+ renderCall: renderToolCall("Loop", (args) => `update · #${String(toolArg(args, "id") ?? "?")} · ${String(toolArg(args, "status") ?? "continue")}`),
536
+ renderResult: renderToolResult,
270
537
  description: `Update progress for a dynamic loop.
271
538
 
272
- Use this after a dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval, "completed" when the goal is done, or "paused" to stop temporarily.`,
539
+ Use this exactly once after each dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval whenever any work remains, "completed" only when the overall goal and done criteria are satisfied, or "paused" when genuinely blocked. Do not use LoopDelete to finish an iteration.`,
273
540
  parameters: Type.Object({
274
541
  id: Type.String({ description: "Dynamic loop ID to update" }),
275
542
  status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
@@ -283,24 +550,47 @@ Use this after a dynamic loop wake. Mark status as "continue" with updated state
283
550
  const store = getStore();
284
551
  const triggerSystem = getTriggerSystem();
285
552
  const entry = store.get(params.id);
286
- if (!entry)
287
- return Promise.resolve(textResult(`Loop #${params.id} not found`));
553
+ if (!entry) {
554
+ return Promise.resolve(textResult(`Loop #${params.id} not found`, {
555
+ kind: "loop", action: "update", tone: "error", summary: `Loop #${params.id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
556
+ }));
557
+ }
288
558
  if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
289
- return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
559
+ return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`, {
560
+ kind: "loop", action: "update", tone: "error", summary: `Loop #${params.id} is not dynamic`, expanded: ["Use LoopUpdate only for dynamic loops."],
561
+ }));
290
562
  }
291
563
  const message = params.status === "continue"
292
564
  ? continueDynamicLoop(params, entry, store, triggerSystem)
293
565
  : stopDynamicLoop(params, store, triggerSystem);
294
566
  updateWidget();
295
- return Promise.resolve(textResult(message));
567
+ const tone = params.status === "paused" ? "warning" : "success";
568
+ const summary = params.status === "completed"
569
+ ? `Loop #${params.id} completed`
570
+ : params.status === "paused"
571
+ ? `Loop #${params.id} paused`
572
+ : `Loop #${params.id} updated`;
573
+ return Promise.resolve(textResult(message, {
574
+ kind: "loop",
575
+ action: "update",
576
+ tone,
577
+ summary,
578
+ expanded: params.status === "continue"
579
+ ? [`State: ${params.state ?? entry.dynamic.state ?? "unchanged"}`, `Next wake: ${params.nextInterval ?? "when idle"}`]
580
+ : [],
581
+ }));
296
582
  },
297
583
  });
298
584
  pi.registerTool({
299
585
  name: "LoopDelete",
300
586
  label: "LoopDelete",
587
+ renderCall: renderToolCall("Loop", (args) => `${String(toolArg(args, "action") ?? "delete")} · #${String(toolArg(args, "id") ?? "?")}`),
588
+ renderResult: renderToolResult,
301
589
  description: `Delete or pause a loop by its ID.
302
590
 
303
- Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.`,
591
+ Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
592
+
593
+ Do not use this after a normal loop fire, an unchanged check, an empty iteration, or one step of a dynamic goal. Recurring loops remain active across iterations; dynamic loops use LoopUpdate. Delete only when the user explicitly asks to cancel the loop or its stated stop condition is satisfied.`,
304
594
  parameters: Type.Object({
305
595
  id: Type.String({ description: "Loop ID to delete or pause" }),
306
596
  action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
@@ -311,23 +601,38 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
311
601
  const entry = getStore().pause(id);
312
602
  if (!entry) {
313
603
  const tombstone = getStore().getDeletionTombstone(id);
314
- if (tombstone)
315
- return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
316
- return Promise.resolve(textResult(`Loop #${id} not found`));
604
+ if (tombstone) {
605
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone), {
606
+ kind: "loop", action: "pause", tone: "warning", summary: `Loop #${id} was already removed`, expanded: [formatDeletionTombstone(id, tombstone)],
607
+ }));
608
+ }
609
+ return Promise.resolve(textResult(`Loop #${id} not found`, {
610
+ kind: "loop", action: "pause", tone: "error", summary: `Loop #${id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
611
+ }));
317
612
  }
318
613
  getTriggerSystem().remove(id);
319
614
  updateWidget();
320
- return Promise.resolve(textResult(`Loop #${id} paused`));
615
+ return Promise.resolve(textResult(`Loop #${id} paused`, {
616
+ kind: "loop", action: "pause", tone: "warning", summary: `Loop #${id} paused`, expanded: ["Use LoopList to inspect paused loops."],
617
+ }));
321
618
  }
322
619
  getTriggerSystem().remove(id);
323
620
  const deleted = getStore().delete(id);
324
621
  updateWidget();
325
- if (deleted)
326
- return Promise.resolve(textResult(`Loop #${id} deleted`));
622
+ if (deleted) {
623
+ return Promise.resolve(textResult(`Loop #${id} deleted`, {
624
+ kind: "loop", action: "delete", tone: "success", summary: `Loop #${id} deleted`, expanded: [],
625
+ }));
626
+ }
327
627
  const tombstone = getStore().getDeletionTombstone(id);
328
- if (tombstone)
329
- return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
330
- return Promise.resolve(textResult(`Loop #${id} not found`));
628
+ if (tombstone) {
629
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone), {
630
+ kind: "loop", action: "delete", tone: "warning", summary: `Loop #${id} was already removed`, expanded: [formatDeletionTombstone(id, tombstone)],
631
+ }));
632
+ }
633
+ return Promise.resolve(textResult(`Loop #${id} not found`, {
634
+ kind: "loop", action: "delete", tone: "error", summary: `Loop #${id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
635
+ }));
331
636
  },
332
637
  });
333
638
  }
@@ -1,5 +1,6 @@
1
1
  import { Type } from "typebox";
2
- import { textResult } from "./tool-result.js";
2
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
3
+ import { displayRows, textResult } from "./tool-result.js";
3
4
  function formatRemaining(ms) {
4
5
  if (ms < 60000)
5
6
  return `${Math.round(ms / 1000)}s`;
@@ -12,6 +13,8 @@ export function registerMonitorTools(options) {
12
13
  pi.registerTool({
13
14
  name: "MonitorCreate",
14
15
  label: "MonitorCreate",
16
+ renderCall: renderToolCall("Monitor", (args) => `start · ${String(toolArg(args, "description") ?? toolArg(args, "command") ?? "monitor").slice(0, 56)}`),
17
+ renderResult: renderToolResult,
15
18
  description: `Run a shell command in the background and get notified when it finishes. The core tool for async/parallel work.
16
19
 
17
20
  Fire off a build check, CI monitor, experiment, script, or any slow command — then keep working. Output streams back as "monitor:output" events. When the process exits, "monitor:done" fires (or "monitor:error" on failure).
@@ -44,7 +47,9 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
44
47
  }),
45
48
  execute(_toolCallId, params) {
46
49
  if (getMonitorManager().list().filter((m) => m.status === "running").length >= 25) {
47
- return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
50
+ return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones.", {
51
+ kind: "monitor", action: "create", tone: "error", summary: "Monitor limit reached", expanded: ["Stop a running monitor before starting another."],
52
+ }));
48
53
  }
49
54
  const entry = getMonitorManager().create(params.command, params.description, params.timeout);
50
55
  updateWidget();
@@ -66,18 +71,33 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
66
71
  }
67
72
  return Promise.resolve(textResult(`Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
68
73
  `Output stream: monitor:output (monitorId: ${entry.id})\n` +
69
- `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`));
74
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`, {
75
+ kind: "monitor",
76
+ action: "create",
77
+ tone: "success",
78
+ summary: `Monitor #${entry.id} running · ${params.description ?? entry.command.slice(0, 48)}`,
79
+ expanded: [
80
+ `Command: ${entry.command}`,
81
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}`,
82
+ params.onDone ? "Completion wake: enabled" : "Completion wake: off",
83
+ ],
84
+ }));
70
85
  },
71
86
  });
72
87
  pi.registerTool({
73
88
  name: "MonitorList",
74
89
  label: "MonitorList",
90
+ renderCall: renderToolCall("Monitor", () => "status"),
91
+ renderResult: renderToolResult,
75
92
  description: "List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.",
76
93
  parameters: Type.Object({}),
77
94
  execute() {
78
95
  const monitors = getMonitorManager().list();
79
- if (monitors.length === 0)
80
- return Promise.resolve(textResult("No monitors."));
96
+ if (monitors.length === 0) {
97
+ return Promise.resolve(textResult("No monitors.", {
98
+ kind: "monitor", action: "list", tone: "info", summary: "No monitors", expanded: ["Use MonitorCreate for long-running background work."],
99
+ }));
100
+ }
81
101
  const lines = [];
82
102
  for (const m of monitors) {
83
103
  const icon = m.status === "running" ? ">" : m.status === "completed" ? "ok" : "x";
@@ -94,12 +114,21 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
94
114
  }
95
115
  }
96
116
  }
97
- return Promise.resolve(textResult(lines.join("\n")));
117
+ const running = monitors.filter((monitor) => monitor.status === "running").length;
118
+ return Promise.resolve(textResult(lines.join("\n"), {
119
+ kind: "monitor",
120
+ action: "list",
121
+ tone: "info",
122
+ summary: `${monitors.length} monitor${monitors.length === 1 ? "" : "s"} · ${running} running`,
123
+ expanded: displayRows(lines),
124
+ }));
98
125
  },
99
126
  });
100
127
  pi.registerTool({
101
128
  name: "MonitorStop",
102
129
  label: "MonitorStop",
130
+ renderCall: renderToolCall("Monitor", (args) => `stop · #${String(toolArg(args, "monitorId") ?? "?")}`),
131
+ renderResult: renderToolResult,
103
132
  description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
104
133
 
105
134
  Use MonitorList to find the monitor ID, then stop it with this tool.`,
@@ -109,9 +138,14 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
109
138
  async execute(_toolCallId, params) {
110
139
  const stopped = await getMonitorManager().stop(params.monitorId);
111
140
  updateWidget();
112
- if (stopped)
113
- return textResult(`Monitor #${params.monitorId} stopped`);
114
- return textResult(`Monitor #${params.monitorId} not found or not running`);
141
+ if (stopped) {
142
+ return textResult(`Monitor #${params.monitorId} stopped`, {
143
+ kind: "monitor", action: "stop", tone: "success", summary: `Monitor #${params.monitorId} stopped`, expanded: [],
144
+ });
145
+ }
146
+ return textResult(`Monitor #${params.monitorId} not found or not running`, {
147
+ kind: "monitor", action: "stop", tone: "error", summary: `Monitor #${params.monitorId} unavailable`, expanded: ["Use MonitorList to find running monitor IDs."],
148
+ });
115
149
  },
116
150
  });
117
151
  }