@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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/dist/api.d.ts +2 -1
- package/dist/index.js +10 -0
- package/dist/loop-reducer.d.ts +24 -1
- package/dist/loop-reducer.js +31 -0
- package/dist/notification-reducer.d.ts +2 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +18 -0
- 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 +320 -21
- 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 +10 -0
- package/src/loop-reducer.ts +53 -1
- package/src/notification-reducer.ts +2 -1
- package/src/runtime/notification-runtime.ts +21 -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 +365 -15
- 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/dist/tools/loop-tools.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
@@ -198,8 +250,15 @@ Recurring loops persist across fires. A completed iteration, unchanged result, o
|
|
|
198
250
|
};
|
|
199
251
|
}
|
|
200
252
|
const validationError = validateTrigger(trigger);
|
|
201
|
-
if (validationError)
|
|
202
|
-
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
|
+
}
|
|
203
262
|
const entry = getStore().create(trigger, prompt, {
|
|
204
263
|
recurring: recurring ?? (inferred !== "event"),
|
|
205
264
|
autoTask,
|
|
@@ -234,20 +293,214 @@ Recurring loops persist across fires. A completed iteration, unchanged result, o
|
|
|
234
293
|
(entry.taskBacklog ? "Backlog worker: enabled\n" : "") +
|
|
235
294
|
(bootstrapped ? "Backlog: initial wake queued for existing pending tasks\n" : "") +
|
|
236
295
|
(isTaskSystemReady() ? "" : "Task system: not ready yet — autoTask may not fire until native fallback or pi-tasks becomes available\n") +
|
|
237
|
-
`ID: ${entry.id} (persists until explicitly canceled or a configured stop condition is met)
|
|
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
|
+
}));
|
|
238
486
|
},
|
|
239
487
|
});
|
|
240
488
|
pi.registerTool({
|
|
241
489
|
name: "LoopList",
|
|
242
490
|
label: "LoopList",
|
|
491
|
+
renderCall: renderToolCall("Loop", () => "status"),
|
|
492
|
+
renderResult: renderToolResult,
|
|
243
493
|
description: `List all active scheduled loops with their IDs, triggers, and next-fire times.
|
|
244
494
|
|
|
245
495
|
Use this before creating new loops to avoid duplicates, or to find IDs for LoopDelete.`,
|
|
246
496
|
parameters: Type.Object({}),
|
|
247
497
|
execute() {
|
|
248
498
|
const loops = getStore().list();
|
|
249
|
-
if (loops.length === 0)
|
|
250
|
-
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
|
+
}
|
|
251
504
|
const lines = [];
|
|
252
505
|
for (const entry of loops) {
|
|
253
506
|
const triggerDesc = formatTrigger(entry.trigger, "list");
|
|
@@ -263,14 +516,24 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
263
516
|
line += " [auto-task]";
|
|
264
517
|
if (entry.taskBacklog)
|
|
265
518
|
line += " [backlog-worker]";
|
|
519
|
+
if (entry.workflow)
|
|
520
|
+
line += ` [workflow:${entry.workflow.currentState}]`;
|
|
266
521
|
lines.push(line);
|
|
267
522
|
}
|
|
268
|
-
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
|
+
}));
|
|
269
530
|
},
|
|
270
531
|
});
|
|
271
532
|
pi.registerTool({
|
|
272
533
|
name: "LoopUpdate",
|
|
273
534
|
label: "LoopUpdate",
|
|
535
|
+
renderCall: renderToolCall("Loop", (args) => `update · #${String(toolArg(args, "id") ?? "?")} · ${String(toolArg(args, "status") ?? "continue")}`),
|
|
536
|
+
renderResult: renderToolResult,
|
|
274
537
|
description: `Update progress for a dynamic loop.
|
|
275
538
|
|
|
276
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.`,
|
|
@@ -287,21 +550,42 @@ Use this exactly once after each dynamic loop wake. Mark status as "continue" wi
|
|
|
287
550
|
const store = getStore();
|
|
288
551
|
const triggerSystem = getTriggerSystem();
|
|
289
552
|
const entry = store.get(params.id);
|
|
290
|
-
if (!entry)
|
|
291
|
-
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
|
+
}
|
|
292
558
|
if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
|
|
293
|
-
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
|
+
}));
|
|
294
562
|
}
|
|
295
563
|
const message = params.status === "continue"
|
|
296
564
|
? continueDynamicLoop(params, entry, store, triggerSystem)
|
|
297
565
|
: stopDynamicLoop(params, store, triggerSystem);
|
|
298
566
|
updateWidget();
|
|
299
|
-
|
|
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
|
+
}));
|
|
300
582
|
},
|
|
301
583
|
});
|
|
302
584
|
pi.registerTool({
|
|
303
585
|
name: "LoopDelete",
|
|
304
586
|
label: "LoopDelete",
|
|
587
|
+
renderCall: renderToolCall("Loop", (args) => `${String(toolArg(args, "action") ?? "delete")} · #${String(toolArg(args, "id") ?? "?")}`),
|
|
588
|
+
renderResult: renderToolResult,
|
|
305
589
|
description: `Delete or pause a loop by its ID.
|
|
306
590
|
|
|
307
591
|
Use "pause" to temporarily stop a loop without removing it. Use "delete" to permanently remove it.
|
|
@@ -317,23 +601,38 @@ Do not use this after a normal loop fire, an unchanged check, an empty iteration
|
|
|
317
601
|
const entry = getStore().pause(id);
|
|
318
602
|
if (!entry) {
|
|
319
603
|
const tombstone = getStore().getDeletionTombstone(id);
|
|
320
|
-
if (tombstone)
|
|
321
|
-
return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)
|
|
322
|
-
|
|
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
|
+
}));
|
|
323
612
|
}
|
|
324
613
|
getTriggerSystem().remove(id);
|
|
325
614
|
updateWidget();
|
|
326
|
-
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
|
+
}));
|
|
327
618
|
}
|
|
328
619
|
getTriggerSystem().remove(id);
|
|
329
620
|
const deleted = getStore().delete(id);
|
|
330
621
|
updateWidget();
|
|
331
|
-
if (deleted)
|
|
332
|
-
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
|
+
}
|
|
333
627
|
const tombstone = getStore().getDeletionTombstone(id);
|
|
334
|
-
if (tombstone)
|
|
335
|
-
return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)
|
|
336
|
-
|
|
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
|
+
}));
|
|
337
636
|
},
|
|
338
637
|
});
|
|
339
638
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|