@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
@@ -2,8 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { formatTrigger } from "../loop-format.js";
4
4
  import { parseInterval } from "../loop-parse.js";
5
- import type { LoopEntry, Trigger } from "../types.js";
6
- import { textResult } from "./tool-result.js";
5
+ import type { LoopEntry, Trigger, WorkflowDefinition } from "../types.js";
6
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
7
+ import { validateWorkflowDefinition } from "../workflow-reducer.js";
8
+ import { displayRows, textResult } from "./tool-result.js";
7
9
 
8
10
  interface LoopStoreLike {
9
11
  list(): LoopEntry[];
@@ -14,9 +16,18 @@ interface LoopStoreLike {
14
16
  taskBacklog?: boolean;
15
17
  readOnly?: boolean;
16
18
  maxFires?: number;
19
+ dynamic?: Partial<NonNullable<LoopEntry["dynamic"]>>;
20
+ workflow?: WorkflowDefinition;
17
21
  }): LoopEntry;
18
22
  pause(id: string): LoopEntry | undefined;
19
23
  updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<NonNullable<LoopEntry["dynamic"]>> }): LoopEntry | undefined;
24
+ transitionWorkflow(id: string, input: { outcome: string; evidence?: string; activeTaskId?: string }): {
25
+ entry?: LoopEntry;
26
+ applied: boolean;
27
+ error?: string;
28
+ terminal?: "completed" | "paused";
29
+ };
30
+ setWorkflowActiveTask(id: string, taskId?: string): LoopEntry | undefined;
20
31
  getDeletionTombstone(id: string): { reason: string; pendingCount?: number } | undefined;
21
32
  delete(id: string): boolean;
22
33
  }
@@ -48,6 +59,9 @@ export interface LoopToolsOptions {
48
59
  updateWidget: () => void;
49
60
  maybeBootstrapTaskLoop: (entry: LoopEntry) => Promise<boolean>;
50
61
  isTaskSystemReady: () => boolean;
62
+ onDynamicLoopActivated?: (entry: LoopEntry) => void;
63
+ createWorkflowTask?: (entry: LoopEntry) => Promise<string | undefined>;
64
+ completeWorkflowTask?: (taskId: string) => Promise<boolean>;
51
65
  }
52
66
 
53
67
  function validateTrigger(trigger: Trigger): string | null {
@@ -111,6 +125,57 @@ function resolveNextWakeAt(nextInterval?: string): { nextWakeAt?: number; error?
111
125
  return { nextWakeAt: Date.now() + parsedDelayMs };
112
126
  }
113
127
 
128
+ function parseWorkflowDefinition(input: string): { definition?: WorkflowDefinition; error?: string } {
129
+ try {
130
+ const parsed = JSON.parse(input) as unknown;
131
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
132
+ return { error: "Workflow definition must be a JSON object" };
133
+ }
134
+ const definition = parsed as WorkflowDefinition;
135
+ const validationError = validateWorkflowDefinition(definition);
136
+ return validationError ? { error: validationError } : { definition };
137
+ } catch {
138
+ return { error: "Workflow definition must be valid JSON" };
139
+ }
140
+ }
141
+
142
+ const WORKFLOW_DEFINITION_EXAMPLE =
143
+ '{"version":1,"initialState":"investigate","states":{"investigate":{"prompt":"Investigate the issue.","on":{"found":"done"}},"done":{"prompt":"Report completion.","terminal":"completed"}}}';
144
+
145
+ function formatWorkflowDefinitionError(error: string | undefined): string {
146
+ return `Workflow definition rejected: ${error ?? "unknown validation error"}\n` +
147
+ "Required fields: version: 1, initialState, and states.\n" +
148
+ `Example definition:\n${WORKFLOW_DEFINITION_EXAMPLE}\n` +
149
+ "Next: correct the JSON and call WorkflowCreate again.";
150
+ }
151
+
152
+ function formatWorkflowSummary(entry: LoopEntry, heading: string): string {
153
+ const workflow = entry.workflow!;
154
+ const state = workflow.definition.states[workflow.currentState];
155
+ const outcomes = Object.keys(state?.on ?? {});
156
+ let message = `${heading}\nGoal: ${entry.prompt}\nCurrent state: ${workflow.currentState}`;
157
+ if (state?.prompt) message += `\nInstruction: ${state.prompt}`;
158
+ if (workflow.activeTaskId) {
159
+ message += `\nActive task: #${workflow.activeTaskId}`;
160
+ } else if (state?.task) {
161
+ message += "\nTask: no task was created for this state";
162
+ } else {
163
+ message += "\nTask: none configured for this state";
164
+ }
165
+
166
+ if (state?.terminal) {
167
+ message += `\nTerminal: ${state.terminal}`;
168
+ return message;
169
+ }
170
+ if (outcomes.length === 0) {
171
+ return `${message}\nNeeds attention: this state has no declared outcomes, so it cannot advance.`;
172
+ }
173
+
174
+ message += `\nChoose outcome: ${outcomes.join(", ")}`;
175
+ message += `\nNext: WorkflowTransition({ id: "${entry.id}", outcome: "${outcomes[0]}", evidence: "..." })`;
176
+ return message;
177
+ }
178
+
114
179
  function formatDynamicUpdateResult(id: string, iteration: number | undefined, nextWakeAt: number | undefined): string {
115
180
  const mode = nextWakeAt === undefined
116
181
  ? "Next wake: when idle"
@@ -178,11 +243,16 @@ export function registerLoopTools(options: LoopToolsOptions): void {
178
243
  updateWidget,
179
244
  maybeBootstrapTaskLoop,
180
245
  isTaskSystemReady,
246
+ onDynamicLoopActivated,
247
+ createWorkflowTask,
248
+ completeWorkflowTask,
181
249
  } = options;
182
250
 
183
251
  pi.registerTool({
184
252
  name: "LoopCreate",
185
253
  label: "LoopCreate",
254
+ renderCall: renderToolCall("Loop", (args) => `create · ${String(toolArg(args, "prompt") ?? "scheduled work").slice(0, 56)}`),
255
+ renderResult: renderToolResult,
186
256
  description: `Create a scheduled repeating task (loop) that runs a prompt on a timer or when an event fires.
187
257
 
188
258
  Use this tool whenever the user asks to:
@@ -275,7 +345,15 @@ Recurring loops persist across fires. A completed iteration, unchanged result, o
275
345
  }
276
346
 
277
347
  const validationError = validateTrigger(trigger);
278
- if (validationError) return Promise.resolve(textResult(validationError));
348
+ if (validationError) {
349
+ return Promise.resolve(textResult(validationError, {
350
+ kind: "loop",
351
+ action: "create",
352
+ tone: "error",
353
+ summary: "Loop was not created",
354
+ expanded: [validationError],
355
+ }));
356
+ }
279
357
 
280
358
  const entry = getStore().create(trigger, prompt, {
281
359
  recurring: recurring ?? (inferred !== "event"),
@@ -316,7 +394,224 @@ Recurring loops persist across fires. A completed iteration, unchanged result, o
316
394
  (entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
317
395
  (bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
318
396
  (isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
319
- `ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)`
397
+ `ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)`,
398
+ {
399
+ kind: "loop",
400
+ action: "create",
401
+ tone: "success",
402
+ summary: `Loop #${entry.id} active · ${triggerDesc}`,
403
+ expanded: [
404
+ `Goal: ${entry.prompt}`,
405
+ `Trigger: ${triggerDesc}`,
406
+ entry.autoTask ? "Auto-task: enabled" : "Auto-task: off",
407
+ ],
408
+ },
409
+ ));
410
+ },
411
+ });
412
+
413
+ pi.registerTool({
414
+ name: "WorkflowCreate",
415
+ label: "WorkflowCreate",
416
+ renderCall: renderToolCall("Workflow", (args) => `create · ${String(toolArg(args, "goal") ?? "workflow").slice(0, 56)}`),
417
+ renderResult: renderToolResult,
418
+ description: `Create an opt-in task-driven workflow loop from a JSON state definition.
419
+
420
+ 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.
421
+
422
+ 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.`,
423
+ promptGuidelines: [
424
+ "Use WorkflowCreate only for explicit multi-phase work with stable named outcomes; ordinary reminders, polling, and task backlogs should remain loops or tasks.",
425
+ "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`.",
426
+ "After each workflow wake, call WorkflowTransition with the workflow `id` and one declared `outcome`; include concise `evidence` whenever a branch is chosen.",
427
+ ],
428
+ parameters: Type.Object({
429
+ goal: Type.String({ description: "Overall workflow goal" }),
430
+ definition: Type.String({ description: "Workflow JSON: version, initialState, and named states" }),
431
+ maxFires: Type.Optional(Type.Number({ description: "Maximum workflow wakes before automatic expiry (default: 30)", default: 30 })),
432
+ }),
433
+ async execute(_toolCallId, params) {
434
+ const parsed = parseWorkflowDefinition(params.definition);
435
+ if (!parsed.definition) {
436
+ const message = formatWorkflowDefinitionError(parsed.error);
437
+ return textResult(message, {
438
+ kind: "workflow",
439
+ action: "create",
440
+ tone: "error",
441
+ summary: "Workflow definition rejected",
442
+ expanded: [parsed.error ?? "unknown validation error", "Expand the tool result for a valid definition skeleton."],
443
+ });
444
+ }
445
+
446
+ let entry = getStore().create({ type: "dynamic" }, params.goal, {
447
+ recurring: true,
448
+ maxFires: params.maxFires ?? 30,
449
+ dynamic: { goal: params.goal, state: parsed.definition.initialState, iteration: 0 },
450
+ workflow: parsed.definition,
451
+ });
452
+ const taskId = await createWorkflowTask?.(entry);
453
+ if (taskId) entry = getStore().setWorkflowActiveTask(entry.id, taskId) ?? entry;
454
+ getTriggerSystem().add(entry);
455
+ updateWidget();
456
+ onDynamicLoopActivated?.(entry);
457
+ return textResult(
458
+ `${formatWorkflowSummary(entry, `Workflow #${entry.id} created — ${entry.status}`)}\n` +
459
+ "Wake: the state instruction will be delivered when the agent becomes idle.",
460
+ {
461
+ kind: "workflow",
462
+ action: "create",
463
+ tone: "success",
464
+ summary: `Workflow #${entry.id} active · ${parsed.definition.initialState}${taskId ? ` · task #${taskId}` : ""}`,
465
+ expanded: [
466
+ `Goal: ${entry.prompt}`,
467
+ `State: ${parsed.definition.initialState}`,
468
+ `Outcome: ${Object.keys(parsed.definition.states[parsed.definition.initialState]?.on ?? {}).join(", ") || "none"}`,
469
+ "Wake: delivered when the agent becomes idle",
470
+ ],
471
+ },
472
+ );
473
+ },
474
+ });
475
+
476
+ pi.registerTool({
477
+ name: "WorkflowTransition",
478
+ label: "WorkflowTransition",
479
+ renderCall: renderToolCall("Workflow", (args) => `transition · #${String(toolArg(args, "id") ?? "?")} → ${String(toolArg(args, "outcome") ?? "?")}`),
480
+ renderResult: renderToolResult,
481
+ description: `Advance an opt-in workflow using one declared outcome.
482
+
483
+ 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.`,
484
+ promptGuidelines: [
485
+ "WorkflowTransition uses `id`, not `loopId`.",
486
+ "Use an exact outcome name declared by the current state. Do not invent an outcome; inspect the wake message or WorkflowList first.",
487
+ "Include `evidence` that justifies the transition, especially for completion, regression, or blocked outcomes.",
488
+ ],
489
+ parameters: Type.Object({
490
+ id: Type.String({ description: "Workflow loop ID" }),
491
+ outcome: Type.String({ description: "Declared outcome for the current workflow state" }),
492
+ evidence: Type.Optional(Type.String({ description: "Concise evidence supporting this transition" })),
493
+ activeTaskId: Type.Optional(Type.String({ description: "Optional task ID now active in the destination state" })),
494
+ }),
495
+ async execute(_toolCallId, params) {
496
+ const store = getStore();
497
+ const sourceTaskId = store.get(params.id)?.workflow?.activeTaskId;
498
+ const result = store.transitionWorkflow(params.id, {
499
+ outcome: params.outcome,
500
+ evidence: params.evidence,
501
+ activeTaskId: params.activeTaskId,
502
+ });
503
+ if (!result.applied || !result.entry) {
504
+ const current = store.get(params.id);
505
+ if (current?.workflow) {
506
+ return textResult(
507
+ `Workflow #${params.id} did not transition\n` +
508
+ `Reason: ${result.error ?? "unknown transition error"}\n` +
509
+ formatWorkflowSummary(current, `Workflow #${params.id} remains — ${current.status}`),
510
+ {
511
+ kind: "workflow",
512
+ action: "transition",
513
+ tone: "error",
514
+ summary: `Workflow #${params.id} remains in ${current.workflow.currentState}`,
515
+ expanded: [result.error ?? "unknown transition error"],
516
+ },
517
+ );
518
+ }
519
+ return textResult(result.error ?? `Workflow loop #${params.id} did not transition`);
520
+ }
521
+
522
+ const entry = result.entry;
523
+ getTriggerSystem().remove(entry.id);
524
+ const sourceTaskClosed = sourceTaskId ? await completeWorkflowTask?.(sourceTaskId) : undefined;
525
+ if (result.terminal === "completed") {
526
+ store.delete(entry.id);
527
+ updateWidget();
528
+ return textResult(
529
+ `Workflow #${entry.id} completed and deleted\n` +
530
+ `Final transition: ${entry.workflow?.lastTransition?.from ?? "?"} → ${entry.workflow?.currentState ?? "?"}\n` +
531
+ "Next: no further workflow transition is needed.",
532
+ {
533
+ kind: "workflow",
534
+ action: "transition",
535
+ tone: "success",
536
+ summary: `Workflow #${entry.id} completed${sourceTaskClosed ? ` · task #${sourceTaskId} closed` : ""}`,
537
+ expanded: [
538
+ `Final transition: ${entry.workflow?.lastTransition?.from ?? "?"} → ${entry.workflow?.currentState ?? "?"}`,
539
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
540
+ ],
541
+ },
542
+ );
543
+ }
544
+ if (result.terminal === "paused") {
545
+ store.pause(entry.id);
546
+ updateWidget();
547
+ return textResult(
548
+ `Workflow #${entry.id} paused\n` +
549
+ `Final state: ${entry.workflow?.currentState ?? "?"}\n` +
550
+ "Next: inspect it with WorkflowList before deciding whether to resume or delete it.",
551
+ {
552
+ kind: "workflow",
553
+ action: "transition",
554
+ tone: "warning",
555
+ summary: `Workflow #${entry.id} paused · ${entry.workflow?.currentState ?? "?"}`,
556
+ expanded: [
557
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
558
+ "Inspect WorkflowList before resuming or deleting this workflow.",
559
+ ],
560
+ },
561
+ );
562
+ }
563
+
564
+ const taskId = await createWorkflowTask?.(entry);
565
+ const updatedEntry = taskId ? store.setWorkflowActiveTask(entry.id, taskId) ?? entry : entry;
566
+ getTriggerSystem().add(updatedEntry);
567
+ updateWidget();
568
+ const from = updatedEntry.workflow?.lastTransition?.from ?? "?";
569
+ const to = updatedEntry.workflow?.currentState ?? "?";
570
+ return textResult(
571
+ `Workflow #${updatedEntry.id} advanced: ${from} → ${to}\n` +
572
+ formatWorkflowSummary(updatedEntry, `Workflow #${updatedEntry.id} — ${updatedEntry.status}`),
573
+ {
574
+ kind: "workflow",
575
+ action: "transition",
576
+ tone: "success",
577
+ summary: `Workflow #${updatedEntry.id} · ${from} → ${to}${taskId ? ` · task #${taskId}` : ""}`,
578
+ expanded: [
579
+ `Instruction: ${updatedEntry.workflow?.definition.states[to]?.prompt ?? ""}`,
580
+ `Outcome: ${Object.keys(updatedEntry.workflow?.definition.states[to]?.on ?? {}).join(", ") || "none"}`,
581
+ sourceTaskId ? `Source task #${sourceTaskId}: ${sourceTaskClosed ? "completed" : "not completed"}` : "Source task: none",
582
+ ],
583
+ },
584
+ );
585
+ },
586
+ });
587
+
588
+ pi.registerTool({
589
+ name: "WorkflowList",
590
+ label: "WorkflowList",
591
+ renderCall: renderToolCall("Workflow", () => "status"),
592
+ renderResult: renderToolResult,
593
+ 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.",
594
+ parameters: Type.Object({}),
595
+ execute() {
596
+ const workflows = getStore().list().filter((entry) => entry.workflow);
597
+ if (workflows.length === 0) {
598
+ return Promise.resolve(textResult(
599
+ "No workflow loops configured.\n" +
600
+ "Next: use WorkflowCreate for explicit state-and-outcome work, or LoopCreate for an ordinary schedule.",
601
+ { kind: "workflow", action: "list", tone: "info", summary: "No workflows", expanded: ["Use WorkflowCreate for state-and-outcome work."] },
602
+ ));
603
+ }
604
+
605
+ const lines = workflows.map((entry) => formatWorkflowSummary(entry, `Workflow #${entry.id} — ${entry.status}`));
606
+ return Promise.resolve(textResult(
607
+ `${workflows.length} workflow${workflows.length === 1 ? "" : "s"} configured\n\n${lines.join("\n\n")}`,
608
+ {
609
+ kind: "workflow",
610
+ action: "list",
611
+ tone: "info",
612
+ summary: `${workflows.length} workflow${workflows.length === 1 ? "" : "s"} · ${workflows.filter((entry) => entry.status === "active").length} active`,
613
+ expanded: displayRows(workflows.map((entry) => `#${entry.id} · ${entry.status} · ${entry.workflow?.currentState ?? "?"} · ${entry.prompt}`)),
614
+ },
320
615
  ));
321
616
  },
322
617
  });
@@ -324,13 +619,19 @@ Recurring loops persist across fires. A completed iteration, unchanged result, o
324
619
  pi.registerTool({
325
620
  name: "LoopList",
326
621
  label: "LoopList",
622
+ renderCall: renderToolCall("Loop", () => "status"),
623
+ renderResult: renderToolResult,
327
624
  description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
328
625
 
329
626
  Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
330
627
  parameters: Type.Object({}),
331
628
  execute() {
332
629
  const loops = getStore().list();
333
- if (loops.length === 0) return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
630
+ if (loops.length === 0) {
631
+ return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule.", {
632
+ kind: "loop", action: "list", tone: "info", summary: "No loops", expanded: ["Use LoopCreate to set up a schedule."],
633
+ }));
634
+ }
334
635
 
335
636
  const lines: string[] = [];
336
637
  for (const entry of loops) {
@@ -346,16 +647,25 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
346
647
  }
347
648
  if (entry.autoTask) line += " [auto-task]";
348
649
  if (entry.taskBacklog) line += " [backlog-worker]";
650
+ if (entry.workflow) line += ` [workflow:${entry.workflow.currentState}]`;
349
651
  lines.push(line);
350
652
  }
351
653
 
352
- return Promise.resolve(textResult(lines.join("\n")));
654
+ return Promise.resolve(textResult(lines.join("\n"), {
655
+ kind: "loop",
656
+ action: "list",
657
+ tone: "info",
658
+ summary: `${loops.length} loop${loops.length === 1 ? "" : "s"} · ${loops.filter((entry) => entry.status === "active").length} active`,
659
+ expanded: displayRows(lines),
660
+ }));
353
661
  },
354
662
  });
355
663
 
356
664
  pi.registerTool({
357
665
  name: "LoopUpdate",
358
666
  label: "LoopUpdate",
667
+ renderCall: renderToolCall("Loop", (args) => `update · #${String(toolArg(args, "id") ?? "?")} · ${String(toolArg(args, "status") ?? "continue")}`),
668
+ renderResult: renderToolResult,
359
669
  description: `Update progress for a dynamic loop.
360
670
 
361
671
  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.`,
@@ -372,22 +682,44 @@ Use this exactly once after each dynamic loop wake. Mark status as "continue" wi
372
682
  const store = getStore();
373
683
  const triggerSystem = getTriggerSystem();
374
684
  const entry = store.get(params.id);
375
- if (!entry) return Promise.resolve(textResult(`Loop #${params.id} not found`));
685
+ if (!entry) {
686
+ return Promise.resolve(textResult(`Loop #${params.id} not found`, {
687
+ kind: "loop", action: "update", tone: "error", summary: `Loop #${params.id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
688
+ }));
689
+ }
376
690
  if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
377
- return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
691
+ return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`, {
692
+ kind: "loop", action: "update", tone: "error", summary: `Loop #${params.id} is not dynamic`, expanded: ["Use LoopUpdate only for dynamic loops."],
693
+ }));
378
694
  }
379
695
 
380
696
  const message = params.status === "continue"
381
697
  ? continueDynamicLoop(params, entry as LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> }, store, triggerSystem)
382
698
  : stopDynamicLoop(params, store, triggerSystem);
383
699
  updateWidget();
384
- return Promise.resolve(textResult(message));
700
+ const tone = params.status === "paused" ? "warning" : "success";
701
+ const summary = params.status === "completed"
702
+ ? `Loop #${params.id} completed`
703
+ : params.status === "paused"
704
+ ? `Loop #${params.id} paused`
705
+ : `Loop #${params.id} updated`;
706
+ return Promise.resolve(textResult(message, {
707
+ kind: "loop",
708
+ action: "update",
709
+ tone,
710
+ summary,
711
+ expanded: params.status === "continue"
712
+ ? [`State: ${params.state ?? entry.dynamic.state ?? "unchanged"}`, `Next wake: ${params.nextInterval ?? "when idle"}`]
713
+ : [],
714
+ }));
385
715
  },
386
716
  });
387
717
 
388
718
  pi.registerTool({
389
719
  name: "LoopDelete",
390
720
  label: "LoopDelete",
721
+ renderCall: renderToolCall("Loop", (args) => `${String(toolArg(args, "action") ?? "delete")} · #${String(toolArg(args, "id") ?? "?")}`),
722
+ renderResult: renderToolResult,
391
723
  description: `Delete or pause a loop by its ID.
392
724
 
393
725
  Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
@@ -404,21 +736,39 @@ Do not use this after a normal loop fire, an unchanged check, an empty iteration
404
736
  const entry = getStore().pause(id);
405
737
  if (!entry) {
406
738
  const tombstone = getStore().getDeletionTombstone(id);
407
- if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
408
- return Promise.resolve(textResult(`Loop #${id} not found`));
739
+ if (tombstone) {
740
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone), {
741
+ kind: "loop", action: "pause", tone: "warning", summary: `Loop #${id} was already removed`, expanded: [formatDeletionTombstone(id, tombstone)],
742
+ }));
743
+ }
744
+ return Promise.resolve(textResult(`Loop #${id} not found`, {
745
+ kind: "loop", action: "pause", tone: "error", summary: `Loop #${id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
746
+ }));
409
747
  }
410
748
  getTriggerSystem().remove(id);
411
749
  updateWidget();
412
- return Promise.resolve(textResult(`Loop #${id} paused`));
750
+ return Promise.resolve(textResult(`Loop #${id} paused`, {
751
+ kind: "loop", action: "pause", tone: "warning", summary: `Loop #${id} paused`, expanded: ["Use LoopList to inspect paused loops."],
752
+ }));
413
753
  }
414
754
 
415
755
  getTriggerSystem().remove(id);
416
756
  const deleted = getStore().delete(id);
417
757
  updateWidget();
418
- if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
758
+ if (deleted) {
759
+ return Promise.resolve(textResult(`Loop #${id} deleted`, {
760
+ kind: "loop", action: "delete", tone: "success", summary: `Loop #${id} deleted`, expanded: [],
761
+ }));
762
+ }
419
763
  const tombstone = getStore().getDeletionTombstone(id);
420
- if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
421
- return Promise.resolve(textResult(`Loop #${id} not found`));
764
+ if (tombstone) {
765
+ return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone), {
766
+ kind: "loop", action: "delete", tone: "warning", summary: `Loop #${id} was already removed`, expanded: [formatDeletionTombstone(id, tombstone)],
767
+ }));
768
+ }
769
+ return Promise.resolve(textResult(`Loop #${id} not found`, {
770
+ kind: "loop", action: "delete", tone: "error", summary: `Loop #${id} not found`, expanded: ["Use LoopList to find valid loop IDs."],
771
+ }));
422
772
  },
423
773
  });
424
774
  }
@@ -1,7 +1,8 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import type { LoopEntry, MonitorEntry, Trigger } from "../types.js";
4
- import { textResult } from "./tool-result.js";
4
+ import { renderToolCall, renderToolResult, toolArg } from "../ui/tool-renderer.js";
5
+ import { displayRows, textResult } from "./tool-result.js";
5
6
 
6
7
  interface MonitorManagerLike {
7
8
  list(): MonitorEntry[];
@@ -39,6 +40,8 @@ export function registerMonitorTools(options: MonitorToolsOptions): void {
39
40
  pi.registerTool({
40
41
  name: "MonitorCreate",
41
42
  label: "MonitorCreate",
43
+ renderCall: renderToolCall("Monitor", (args) => `start · ${String(toolArg(args, "description") ?? toolArg(args, "command") ?? "monitor").slice(0, 56)}`),
44
+ renderResult: renderToolResult,
42
45
  description: `Run a shell command in the background and get notified when it finishes. The core tool for async/parallel work.
43
46
 
44
47
  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).
@@ -71,7 +74,9 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
71
74
  }),
72
75
  execute(_toolCallId, params) {
73
76
  if (getMonitorManager().list().filter((m) => m.status === "running").length >= 25) {
74
- return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones."));
77
+ return Promise.resolve(textResult("Maximum of 25 running monitors reached. Stop some before creating new ones.", {
78
+ kind: "monitor", action: "create", tone: "error", summary: "Monitor limit reached", expanded: ["Stop a running monitor before starting another."],
79
+ }));
75
80
  }
76
81
 
77
82
  const entry = getMonitorManager().create(params.command, params.description, params.timeout);
@@ -97,7 +102,18 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
97
102
  return Promise.resolve(textResult(
98
103
  `Monitor #${entry.id} started: ${entry.command.slice(0, 60)}\n` +
99
104
  `Output stream: monitor:output (monitorId: ${entry.id})\n` +
100
- `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`
105
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}${onDoneMsg}`,
106
+ {
107
+ kind: "monitor",
108
+ action: "create",
109
+ tone: "success",
110
+ summary: `Monitor #${entry.id} running · ${params.description ?? entry.command.slice(0, 48)}`,
111
+ expanded: [
112
+ `Command: ${entry.command}`,
113
+ `Timeout: ${params.timeout ? `${params.timeout / 1000}s` : "none"}`,
114
+ params.onDone ? "Completion wake: enabled" : "Completion wake: off",
115
+ ],
116
+ },
101
117
  ));
102
118
  },
103
119
  });
@@ -105,11 +121,17 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
105
121
  pi.registerTool({
106
122
  name: "MonitorList",
107
123
  label: "MonitorList",
124
+ renderCall: renderToolCall("Monitor", () => "status"),
125
+ renderResult: renderToolResult,
108
126
  description: "List all monitors with their status, command, exit code, output line count, and last 5 lines of buffered output.",
109
127
  parameters: Type.Object({}),
110
128
  execute() {
111
129
  const monitors = getMonitorManager().list();
112
- if (monitors.length === 0) return Promise.resolve(textResult("No monitors."));
130
+ if (monitors.length === 0) {
131
+ return Promise.resolve(textResult("No monitors.", {
132
+ kind: "monitor", action: "list", tone: "info", summary: "No monitors", expanded: ["Use MonitorCreate for long-running background work."],
133
+ }));
134
+ }
113
135
 
114
136
  const lines: string[] = [];
115
137
  for (const m of monitors) {
@@ -128,13 +150,22 @@ Pass onDone with a prompt and the monitor auto-creates a one-shot loop that fire
128
150
  }
129
151
  }
130
152
 
131
- return Promise.resolve(textResult(lines.join("\n")));
153
+ const running = monitors.filter((monitor) => monitor.status === "running").length;
154
+ return Promise.resolve(textResult(lines.join("\n"), {
155
+ kind: "monitor",
156
+ action: "list",
157
+ tone: "info",
158
+ summary: `${monitors.length} monitor${monitors.length === 1 ? "" : "s"} · ${running} running`,
159
+ expanded: displayRows(lines),
160
+ }));
132
161
  },
133
162
  });
134
163
 
135
164
  pi.registerTool({
136
165
  name: "MonitorStop",
137
166
  label: "MonitorStop",
167
+ renderCall: renderToolCall("Monitor", (args) => `stop · #${String(toolArg(args, "monitorId") ?? "?")}`),
168
+ renderResult: renderToolResult,
138
169
  description: `Stop a running monitor. Sends SIGTERM, waits 5s, then SIGKILL.
139
170
 
140
171
  Use MonitorList to find the monitor ID, then stop it with this tool.`,
@@ -144,8 +175,14 @@ Use MonitorList to find the monitor ID, then stop it with this tool.`,
144
175
  async execute(_toolCallId, params) {
145
176
  const stopped = await getMonitorManager().stop(params.monitorId);
146
177
  updateWidget();
147
- if (stopped) return textResult(`Monitor #${params.monitorId} stopped`);
148
- return textResult(`Monitor #${params.monitorId} not found or not running`);
178
+ if (stopped) {
179
+ return textResult(`Monitor #${params.monitorId} stopped`, {
180
+ kind: "monitor", action: "stop", tone: "success", summary: `Monitor #${params.monitorId} stopped`, expanded: [],
181
+ });
182
+ }
183
+ return textResult(`Monitor #${params.monitorId} not found or not running`, {
184
+ kind: "monitor", action: "stop", tone: "error", summary: `Monitor #${params.monitorId} unavailable`, expanded: ["Use MonitorList to find running monitor IDs."],
185
+ });
149
186
  },
150
187
  });
151
188
  }