@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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +14 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +4 -1
- package/dist/runtime/notification-runtime.d.ts +4 -1
- package/dist/runtime/notification-runtime.js +27 -1
- package/dist/runtime/session-runtime.js +14 -3
- package/dist/runtime/task-backlog-runtime.d.ts +1 -1
- package/dist/runtime/task-backlog-runtime.js +1 -1
- package/dist/runtime/task-events.d.ts +2 -1
- package/dist/runtime/task-events.js +1 -0
- package/dist/runtime/task-rpc.d.ts +4 -0
- package/dist/runtime/task-rpc.js +62 -1
- package/dist/store.d.ts +10 -1
- package/dist/store.js +51 -0
- package/dist/task-reducer.d.ts +2 -1
- package/dist/task-reducer.js +1 -0
- package/dist/task-store.d.ts +2 -2
- package/dist/task-store.js +2 -2
- package/dist/task-types.d.ts +6 -0
- package/dist/tools/loop-tools.d.ts +17 -1
- package/dist/tools/loop-tools.js +331 -26
- package/dist/tools/monitor-tools.js +43 -9
- package/dist/tools/native-task-tools.js +56 -11
- package/dist/tools/tool-result.d.ts +12 -2
- package/dist/tools/tool-result.js +7 -3
- package/dist/types.d.ts +35 -0
- package/dist/ui/tool-renderer.d.ts +7 -0
- package/dist/ui/tool-renderer.js +34 -0
- package/dist/ui/widget.js +4 -4
- package/dist/workflow-reducer.d.ts +18 -0
- package/dist/workflow-reducer.js +82 -0
- package/docs/USAGE_GUIDE.md +41 -0
- package/package.json +3 -3
- package/src/api.ts +9 -1
- package/src/index.ts +14 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +4 -1
- package/src/runtime/notification-runtime.ts +34 -2
- package/src/runtime/session-runtime.ts +15 -3
- package/src/runtime/task-backlog-runtime.ts +1 -1
- package/src/runtime/task-events.ts +3 -1
- package/src/runtime/task-rpc.ts +66 -0
- package/src/store.ts +51 -2
- package/src/task-reducer.ts +3 -1
- package/src/task-store.ts +3 -3
- package/src/task-types.ts +7 -0
- package/src/tools/loop-tools.ts +376 -20
- package/src/tools/monitor-tools.ts +44 -7
- package/src/tools/native-task-tools.ts +56 -8
- package/src/tools/tool-result.ts +18 -2
- package/src/types.ts +41 -0
- package/src/ui/tool-renderer.ts +45 -0
- package/src/ui/widget.ts +4 -4
- package/src/workflow-reducer.ts +107 -0
package/src/tools/loop-tools.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
@@ -214,7 +284,11 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
214
284
|
- **autoTask**: when pi-tasks is loaded or native task fallback is active, auto-create a task on each fire
|
|
215
285
|
- **taskBacklog**: mark this as a task-backlog worker loop so it auto-deletes when pending tasks reach zero
|
|
216
286
|
- **readOnly**: restrict the agent to read-only tools when this loop fires (default: false)
|
|
217
|
-
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
287
|
+
- **maxFires**: auto-stop after N fires — prevents infinite token burn on polling loops
|
|
288
|
+
|
|
289
|
+
## Loop Lifecycle
|
|
290
|
+
|
|
291
|
+
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.`,
|
|
218
292
|
promptGuidelines: [
|
|
219
293
|
"Use LoopCreate when the user asks for a repeating task, periodic check, scheduled reminder, or 'every X' — never use raw Bash for/sleep/while.",
|
|
220
294
|
"## Choosing trigger type",
|
|
@@ -226,14 +300,14 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
226
300
|
"Default to 5m unless the user specifies differently. Use shorter intervals only when time-sensitive.",
|
|
227
301
|
"## maxFires — prevent infinite token burn",
|
|
228
302
|
"Always set maxFires on polling loops so they don't run forever. For task-continuation loops, use maxFires: 20-50.",
|
|
229
|
-
"
|
|
303
|
+
"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.",
|
|
230
304
|
"## readOnly mode",
|
|
231
305
|
"Set readOnly: true for loops that only observe and report (checks, status polls). This prevents unintended changes.",
|
|
232
306
|
"## Task-driven workflows",
|
|
233
307
|
"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.",
|
|
234
308
|
"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.",
|
|
235
309
|
"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.",
|
|
236
|
-
"
|
|
310
|
+
"For taskBacklog loops, do not instruct the agent to delete the loop; pi-loop auto-deletes it when the pending count reaches zero.",
|
|
237
311
|
"After creating a loop, tell the user the loop ID so they can cancel it with LoopDelete.",
|
|
238
312
|
],
|
|
239
313
|
parameters: Type.Object({
|
|
@@ -271,7 +345,15 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
271
345
|
}
|
|
272
346
|
|
|
273
347
|
const validationError = validateTrigger(trigger);
|
|
274
|
-
if (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
|
+
}
|
|
275
357
|
|
|
276
358
|
const entry = getStore().create(trigger, prompt, {
|
|
277
359
|
recurring: recurring ?? (inferred !== "event"),
|
|
@@ -312,7 +394,224 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
312
394
|
(entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
|
|
313
395
|
(bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
|
|
314
396
|
(isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
|
|
315
|
-
`ID: ${entry.id} (
|
|
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
|
+
},
|
|
316
615
|
));
|
|
317
616
|
},
|
|
318
617
|
});
|
|
@@ -320,13 +619,19 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
320
619
|
pi.registerTool({
|
|
321
620
|
name: "LoopList",
|
|
322
621
|
label: "LoopList",
|
|
622
|
+
renderCall: renderToolCall("Loop", () => "status"),
|
|
623
|
+
renderResult: renderToolResult,
|
|
323
624
|
description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
|
|
324
625
|
|
|
325
626
|
Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
|
|
326
627
|
parameters: Type.Object({}),
|
|
327
628
|
execute() {
|
|
328
629
|
const loops = getStore().list();
|
|
329
|
-
if (loops.length === 0)
|
|
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
|
+
}
|
|
330
635
|
|
|
331
636
|
const lines: string[] = [];
|
|
332
637
|
for (const entry of loops) {
|
|
@@ -342,19 +647,28 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
342
647
|
}
|
|
343
648
|
if (entry.autoTask) line += " [auto-task]";
|
|
344
649
|
if (entry.taskBacklog) line += " [backlog-worker]";
|
|
650
|
+
if (entry.workflow) line += ` [workflow:${entry.workflow.currentState}]`;
|
|
345
651
|
lines.push(line);
|
|
346
652
|
}
|
|
347
653
|
|
|
348
|
-
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
|
+
}));
|
|
349
661
|
},
|
|
350
662
|
});
|
|
351
663
|
|
|
352
664
|
pi.registerTool({
|
|
353
665
|
name: "LoopUpdate",
|
|
354
666
|
label: "LoopUpdate",
|
|
667
|
+
renderCall: renderToolCall("Loop", (args) => `update · #${String(toolArg(args, "id") ?? "?")} · ${String(toolArg(args, "status") ?? "continue")}`),
|
|
668
|
+
renderResult: renderToolResult,
|
|
355
669
|
description: `Update progress for a dynamic loop.
|
|
356
670
|
|
|
357
|
-
Use this after
|
|
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.`,
|
|
358
672
|
parameters: Type.Object({
|
|
359
673
|
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
360
674
|
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
@@ -368,25 +682,49 @@ Use this after a dynamic loop wake. Mark status as "continue" with updated state
|
|
|
368
682
|
const store = getStore();
|
|
369
683
|
const triggerSystem = getTriggerSystem();
|
|
370
684
|
const entry = store.get(params.id);
|
|
371
|
-
if (!entry)
|
|
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
|
+
}
|
|
372
690
|
if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
|
|
373
|
-
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
|
+
}));
|
|
374
694
|
}
|
|
375
695
|
|
|
376
696
|
const message = params.status === "continue"
|
|
377
697
|
? continueDynamicLoop(params, entry as LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> }, store, triggerSystem)
|
|
378
698
|
: stopDynamicLoop(params, store, triggerSystem);
|
|
379
699
|
updateWidget();
|
|
380
|
-
|
|
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
|
+
}));
|
|
381
715
|
},
|
|
382
716
|
});
|
|
383
717
|
|
|
384
718
|
pi.registerTool({
|
|
385
719
|
name: "LoopDelete",
|
|
386
720
|
label: "LoopDelete",
|
|
721
|
+
renderCall: renderToolCall("Loop", (args) => `${String(toolArg(args, "action") ?? "delete")} · #${String(toolArg(args, "id") ?? "?")}`),
|
|
722
|
+
renderResult: renderToolResult,
|
|
387
723
|
description: `Delete or pause a loop by its ID.
|
|
388
724
|
|
|
389
|
-
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it
|
|
725
|
+
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
|
|
726
|
+
|
|
727
|
+
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.`,
|
|
390
728
|
parameters: Type.Object({
|
|
391
729
|
id: Type.String({ description: "Loop ID to delete or pause" }),
|
|
392
730
|
action: Type.Optional(Type.String({ description: "delete or pause (default: delete)", enum: ["delete", "pause"], default: "delete" })),
|
|
@@ -398,21 +736,39 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
398
736
|
const entry = getStore().pause(id);
|
|
399
737
|
if (!entry) {
|
|
400
738
|
const tombstone = getStore().getDeletionTombstone(id);
|
|
401
|
-
if (tombstone)
|
|
402
|
-
|
|
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
|
+
}));
|
|
403
747
|
}
|
|
404
748
|
getTriggerSystem().remove(id);
|
|
405
749
|
updateWidget();
|
|
406
|
-
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
|
+
}));
|
|
407
753
|
}
|
|
408
754
|
|
|
409
755
|
getTriggerSystem().remove(id);
|
|
410
756
|
const deleted = getStore().delete(id);
|
|
411
757
|
updateWidget();
|
|
412
|
-
if (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
|
+
}
|
|
413
763
|
const tombstone = getStore().getDeletionTombstone(id);
|
|
414
|
-
if (tombstone)
|
|
415
|
-
|
|
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
|
+
}));
|
|
416
772
|
},
|
|
417
773
|
});
|
|
418
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 {
|
|
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)
|
|
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
|
-
|
|
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)
|
|
148
|
-
|
|
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
|
}
|