@signalridge/pi-workflows 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -0
- package/README.md +54 -12
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +11 -6
- package/skills/workflow-authoring/references/capabilities.md +3 -3
- package/skills/workflow-authoring/references/capability-details.md +7 -5
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +5 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- package/skills/workflow-authoring/references/versions.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/builtins.ts +23 -23
- package/src/capabilities.ts +11 -4
- package/src/engine.ts +238 -58
- package/src/index.ts +292 -150
- package/src/journal.ts +292 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +176 -44
- package/src/ui.ts +29 -4
package/src/index.ts
CHANGED
|
@@ -8,7 +8,12 @@ import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
|
|
|
8
8
|
import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
|
|
9
9
|
import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
|
|
10
10
|
import { JOURNAL_ENTRY_TYPE, type SessionEntryLike } from "./journal.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createManagedSpawnClient,
|
|
13
|
+
type ManagedProtocolCheck,
|
|
14
|
+
PROTOCOL_DIAGNOSTIC,
|
|
15
|
+
queryChildSessionContextImmediate,
|
|
16
|
+
} from "./rpc-client.js";
|
|
12
17
|
import { listSavedWorkflows, loadSavedWorkflow, saveWorkflow } from "./saved-workflows.js";
|
|
13
18
|
import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
|
|
14
19
|
import { loadWorkflowSettings, saveWorkflowSettings } from "./workflow-settings.js";
|
|
@@ -124,6 +129,7 @@ export function buildAdHocWorkflowScript(prompt: string): string {
|
|
|
124
129
|
const originalPrompt = ${JSON.stringify(prompt)};
|
|
125
130
|
const plan = await agent("Design a bounded execution plan for this user task. Return 1-8 independent or dependency-linked tasks. Use unique short ids, concise descriptions, exact worker prompts, and only dependencies that appear in the task list. Prefer independent tasks when possible. User task:\\n\\n" + originalPrompt, {
|
|
126
131
|
label: "planner",
|
|
132
|
+
tier: "medium",
|
|
127
133
|
schema: {
|
|
128
134
|
type: "object",
|
|
129
135
|
properties: {
|
|
@@ -152,7 +158,7 @@ const graph = await orchestrate(plan.tasks.map((task) => ({
|
|
|
152
158
|
id: task.id,
|
|
153
159
|
description: task.description,
|
|
154
160
|
dependsOn: task.dependsOn || [],
|
|
155
|
-
run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id }),
|
|
161
|
+
run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, tier: "low" }),
|
|
156
162
|
})), { onError: "continue" });
|
|
157
163
|
const SYNTHESIS_CONTEXT_LIMIT = 78000;
|
|
158
164
|
function renderContextValue(value) {
|
|
@@ -226,21 +232,61 @@ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 1
|
|
|
226
232
|
synthesisJson = JSON.stringify(synthesisContext);
|
|
227
233
|
}
|
|
228
234
|
if (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT) throw new Error("Unable to fit the synthesis context within its managed prompt budget");
|
|
229
|
-
return await agent("Synthesize the worker results into a direct answer to the original task. Mention incomplete or failed coverage explicitly.\\n\\nOriginal task, execution plan, and graph ledger:\\n" + synthesisJson, { label: "synthesizer" });`;
|
|
235
|
+
return await agent("Synthesize the worker results into a direct answer to the original task. Mention incomplete or failed coverage explicitly.\\n\\nOriginal task, execution plan, and graph ledger:\\n" + synthesisJson, { label: "synthesizer", tier: "medium" });`;
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
export default function piWorkflows(pi: ExtensionAPI): void {
|
|
233
239
|
let engine: WorkflowEngine | undefined;
|
|
234
240
|
let protocolError: string | undefined;
|
|
235
|
-
let protocolCheck: Promise<
|
|
241
|
+
let protocolCheck: Promise<ManagedProtocolCheck> | undefined;
|
|
242
|
+
let protocolProbe: (() => Promise<ManagedProtocolCheck>) | undefined;
|
|
243
|
+
let protocolInFlight: Promise<ManagedProtocolCheck> | undefined;
|
|
244
|
+
/**
|
|
245
|
+
* Track one in-flight probe so concurrent starts share it, and release it the
|
|
246
|
+
* moment it settles.
|
|
247
|
+
*
|
|
248
|
+
* Releasing matters: the catalogue is read live on the peer's side, so the
|
|
249
|
+
* next start or resume must issue a *new* ping. Holding a settled promise
|
|
250
|
+
* here would pin every later run to the routing policy as it stood at session
|
|
251
|
+
* start, and a tier defined since then would be rejected pre-dispatch as
|
|
252
|
+
* unknown.
|
|
253
|
+
*/
|
|
254
|
+
const trackProbe = (check: Promise<ManagedProtocolCheck>): Promise<ManagedProtocolCheck> => {
|
|
255
|
+
protocolInFlight = check;
|
|
256
|
+
const release = () => {
|
|
257
|
+
if (protocolInFlight === check) protocolInFlight = undefined;
|
|
258
|
+
};
|
|
259
|
+
void check.then(release, release);
|
|
260
|
+
return check;
|
|
261
|
+
};
|
|
262
|
+
const awaitProtocol = async (): Promise<ManagedProtocolCheck> => {
|
|
263
|
+
let check = protocolInFlight;
|
|
264
|
+
if (!check) {
|
|
265
|
+
if (protocolProbe) protocolError = undefined;
|
|
266
|
+
check = protocolProbe?.() ?? protocolCheck;
|
|
267
|
+
if (!check) throw new Error(`${PROTOCOL_DIAGNOSTIC} Diagnostic: no active protocol probe`);
|
|
268
|
+
trackProbe(check);
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const result = await check;
|
|
272
|
+
protocolError = undefined;
|
|
273
|
+
return result;
|
|
274
|
+
} catch (error: unknown) {
|
|
275
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
276
|
+
throw new Error(protocolError ?? `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
236
279
|
let active = false;
|
|
237
|
-
let
|
|
280
|
+
let surfaceRegistered = false;
|
|
281
|
+
const registeredWorkflowCommands = new Set<string>();
|
|
238
282
|
let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
239
283
|
let lifecycleGeneration = 0;
|
|
240
284
|
let protocolAbortController: AbortController | undefined;
|
|
241
285
|
let sessionCwd = process.cwd();
|
|
242
286
|
let workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
243
287
|
let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
|
|
288
|
+
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
289
|
+
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
244
290
|
/** Per-session delivery endpoint for background-run results (A10 fail-closed). */
|
|
245
291
|
let deliverResult: ((text: string, details?: unknown) => void) | undefined;
|
|
246
292
|
/** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
|
|
@@ -252,10 +298,42 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
252
298
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
253
299
|
|
|
254
300
|
const currentEngine = (): WorkflowEngine => {
|
|
255
|
-
if (!
|
|
301
|
+
if (!active || !engine || engine.isDisposed())
|
|
302
|
+
throw new Error("pi-workflows is not active in this session context");
|
|
256
303
|
return engine;
|
|
257
304
|
};
|
|
258
305
|
|
|
306
|
+
const clearWidget = (): void => {
|
|
307
|
+
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
308
|
+
tui?.setWidget?.("pi-workflows", undefined);
|
|
309
|
+
};
|
|
310
|
+
const refreshWidget = (): void => {
|
|
311
|
+
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
312
|
+
if (!tui?.setWidget) return;
|
|
313
|
+
const workflowEngine = engine;
|
|
314
|
+
if (!active || !workflowEngine || workflowEngine.isDisposed()) {
|
|
315
|
+
tui.setWidget("pi-workflows", undefined);
|
|
316
|
+
if (widgetTimer) {
|
|
317
|
+
clearInterval(widgetTimer);
|
|
318
|
+
widgetTimer = undefined;
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const runs = workflowEngine
|
|
323
|
+
.list()
|
|
324
|
+
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
325
|
+
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
326
|
+
const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
327
|
+
tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
|
|
328
|
+
if (lines.length > 0 && !widgetTimer) {
|
|
329
|
+
widgetTimer = setInterval(refreshWidget, 2_000);
|
|
330
|
+
} else if (lines.length === 0 && widgetTimer) {
|
|
331
|
+
clearInterval(widgetTimer);
|
|
332
|
+
widgetTimer = undefined;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
const notifyRunChanged = (): void => refreshWidget();
|
|
336
|
+
|
|
259
337
|
const branchEntries = (): SessionEntryLike[] => {
|
|
260
338
|
const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
|
|
261
339
|
try {
|
|
@@ -267,16 +345,38 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
267
345
|
|
|
268
346
|
const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
|
|
269
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Resolve a nested `workflow(name)` reference, saying where the script came
|
|
350
|
+
* from.
|
|
351
|
+
*
|
|
352
|
+
* Shipped-ness has to be reported per script rather than inherited from the
|
|
353
|
+
* frame that called it: a user script may call a built-in by name, and a
|
|
354
|
+
* built-in's tier names are preferences against whatever catalogue the host
|
|
355
|
+
* defines, while a user script's are assertions about a catalogue the user
|
|
356
|
+
* owns. Inheriting the caller's flag would apply the wrong rule in both
|
|
357
|
+
* directions.
|
|
358
|
+
*/
|
|
359
|
+
const resolveNestedWorkflow = (name: string): { script: string; shippedScript?: boolean } | undefined => {
|
|
360
|
+
// A saved file shadows a built-in of the same name; it is the user's own
|
|
361
|
+
// script and keeps the strict tier check.
|
|
362
|
+
const saved = loadSavedWorkflow(name, sessionCwd);
|
|
363
|
+
if (saved !== undefined) return { script: saved };
|
|
364
|
+
const builtin = BUILTIN_WORKFLOWS[name]?.script;
|
|
365
|
+
return builtin === undefined ? undefined : { script: builtin, shippedScript: true };
|
|
366
|
+
};
|
|
367
|
+
|
|
270
368
|
const resolveScript = (
|
|
271
369
|
script: string | undefined,
|
|
272
370
|
name: string | undefined,
|
|
273
371
|
args: unknown,
|
|
274
|
-
): { script: string; source: string; toolset?: string } => {
|
|
372
|
+
): { script: string; source: string; toolset?: string; shippedScript?: boolean } => {
|
|
275
373
|
if (script !== undefined && name !== undefined) {
|
|
276
374
|
throw new Error("Provide either `script` or `name`, not both.");
|
|
277
375
|
}
|
|
278
376
|
if (script !== undefined) return { script, source: "inline" };
|
|
279
377
|
if (name !== undefined) {
|
|
378
|
+
// A saved workflow is the user's own file even when it shadows a built-in
|
|
379
|
+
// name, so it keeps the strict tier check.
|
|
280
380
|
const saved = loadSavedWorkflow(name, sessionCwd);
|
|
281
381
|
if (saved) return { script: saved, source: `saved:${name}` };
|
|
282
382
|
const builtin = BUILTIN_WORKFLOWS[name];
|
|
@@ -284,7 +384,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
284
384
|
// Validate named invocations before starting a potentially expensive fleet.
|
|
285
385
|
// Slash commands provide the same shape through their descriptor primaryArg.
|
|
286
386
|
validateBuiltinArgs(name, args);
|
|
287
|
-
return {
|
|
387
|
+
return {
|
|
388
|
+
script: builtin.script,
|
|
389
|
+
source: `builtin:${name}`,
|
|
390
|
+
toolset: builtin.toolset,
|
|
391
|
+
shippedScript: true,
|
|
392
|
+
};
|
|
288
393
|
}
|
|
289
394
|
throw new Error(`Unknown workflow name "${name}". It is neither a saved workflow nor a built-in.`);
|
|
290
395
|
}
|
|
@@ -295,6 +400,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
295
400
|
if (active) return;
|
|
296
401
|
active = true;
|
|
297
402
|
const workflowEngine = currentEngine();
|
|
403
|
+
workflowEngine.onRunSettled = notifyRunChanged;
|
|
404
|
+
// Command/tool registrations persist across session replacement, but the
|
|
405
|
+
// widget belongs to the newly restored engine and must be refreshed too.
|
|
406
|
+
refreshWidget();
|
|
407
|
+
if (surfaceRegistered) return;
|
|
408
|
+
surfaceRegistered = true;
|
|
298
409
|
|
|
299
410
|
pi.registerTool(
|
|
300
411
|
defineTool({
|
|
@@ -330,62 +441,67 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
330
441
|
},
|
|
331
442
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
332
443
|
try {
|
|
333
|
-
|
|
444
|
+
const workflowEngine = currentEngine();
|
|
445
|
+
const resolved =
|
|
446
|
+
params.resumeFromRunId && params.script === undefined && params.name === undefined
|
|
447
|
+
? (() => {
|
|
448
|
+
const prior = workflowEngine.getRun(params.resumeFromRunId);
|
|
449
|
+
if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
450
|
+
// Resume reads the frozen flag off the restored run, so
|
|
451
|
+
// this only has to satisfy the shared shape.
|
|
452
|
+
return { script: prior.script, source: "resume", toolset: prior.toolset, shippedScript: undefined };
|
|
453
|
+
})()
|
|
454
|
+
: resolveScript(params.script, params.name, params.args);
|
|
455
|
+
const { script, source, toolset, shippedScript } = resolved;
|
|
456
|
+
const options: ScriptStartOptions = {
|
|
457
|
+
args: params.args,
|
|
458
|
+
background: params.background ?? true,
|
|
459
|
+
maxAgents: params.maxAgents,
|
|
460
|
+
concurrency: params.concurrency,
|
|
461
|
+
agentRetries: params.agentRetries,
|
|
462
|
+
tokenBudget: params.tokenBudget,
|
|
463
|
+
agentTimeoutMs: params.agentTimeoutMs,
|
|
464
|
+
toolset,
|
|
465
|
+
...(shippedScript === undefined ? {} : { shippedScript }),
|
|
466
|
+
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
467
|
+
signal,
|
|
468
|
+
mainModel: ctx?.model?.id,
|
|
469
|
+
// Foreground checkpoint confirmation only when the run is not
|
|
470
|
+
// background — background runs are headless by contract.
|
|
471
|
+
confirm:
|
|
472
|
+
params.background === false && ctx?.hasUI && ctx.mode === "tui"
|
|
473
|
+
? async (promptText, checkpointOptions) => {
|
|
474
|
+
if (checkpointOptions.kind === "input")
|
|
475
|
+
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
|
|
476
|
+
if (checkpointOptions.kind === "select")
|
|
477
|
+
return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
|
|
478
|
+
return ctx.ui.confirm(promptText, promptText);
|
|
479
|
+
}
|
|
480
|
+
: undefined,
|
|
481
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
482
|
+
};
|
|
483
|
+
if (params.resumeFromRunId) {
|
|
484
|
+
const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
|
|
485
|
+
const resumed = await workflowEngine.resume(
|
|
486
|
+
params.resumeFromRunId,
|
|
487
|
+
branchEntries(),
|
|
488
|
+
options,
|
|
489
|
+
replacement,
|
|
490
|
+
);
|
|
491
|
+
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
492
|
+
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
493
|
+
return textResult(formatStart(resumed), resumed);
|
|
494
|
+
}
|
|
495
|
+
const result = await workflowEngine.start(script, options);
|
|
496
|
+
if (result.background) {
|
|
497
|
+
// The engine resolves the script's meta name for the delivery header.
|
|
498
|
+
void source;
|
|
499
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
500
|
+
}
|
|
501
|
+
return textResult(formatStart(result), result);
|
|
334
502
|
} catch (error: unknown) {
|
|
335
|
-
return textResult(
|
|
336
|
-
protocolError ??
|
|
337
|
-
`${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
const resolved =
|
|
341
|
-
params.resumeFromRunId && params.script === undefined && params.name === undefined
|
|
342
|
-
? (() => {
|
|
343
|
-
const prior = workflowEngine.getRun(params.resumeFromRunId);
|
|
344
|
-
if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
345
|
-
return { script: prior.script, source: "resume", toolset: prior.toolset };
|
|
346
|
-
})()
|
|
347
|
-
: resolveScript(params.script, params.name, params.args);
|
|
348
|
-
const { script, source, toolset } = resolved;
|
|
349
|
-
const options: ScriptStartOptions = {
|
|
350
|
-
args: params.args,
|
|
351
|
-
background: params.background ?? true,
|
|
352
|
-
maxAgents: params.maxAgents,
|
|
353
|
-
concurrency: params.concurrency,
|
|
354
|
-
agentRetries: params.agentRetries,
|
|
355
|
-
tokenBudget: params.tokenBudget,
|
|
356
|
-
agentTimeoutMs: params.agentTimeoutMs,
|
|
357
|
-
toolset,
|
|
358
|
-
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
359
|
-
signal,
|
|
360
|
-
mainModel: ctx?.model?.id,
|
|
361
|
-
// Foreground checkpoint confirmation only when the run is not
|
|
362
|
-
// background — background runs are headless by contract.
|
|
363
|
-
confirm:
|
|
364
|
-
params.background === false && ctx?.hasUI && ctx.mode === "tui"
|
|
365
|
-
? async (promptText, checkpointOptions) => {
|
|
366
|
-
if (checkpointOptions.kind === "input")
|
|
367
|
-
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
|
|
368
|
-
if (checkpointOptions.kind === "select")
|
|
369
|
-
return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
|
|
370
|
-
return ctx.ui.confirm(promptText, promptText);
|
|
371
|
-
}
|
|
372
|
-
: undefined,
|
|
373
|
-
loadSavedWorkflow: (name) => loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script,
|
|
374
|
-
};
|
|
375
|
-
if (params.resumeFromRunId) {
|
|
376
|
-
const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
|
|
377
|
-
const resumed = await workflowEngine.resume(params.resumeFromRunId, branchEntries(), options, replacement);
|
|
378
|
-
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
379
|
-
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
380
|
-
return textResult(formatStart(resumed), resumed);
|
|
381
|
-
}
|
|
382
|
-
const result = await workflowEngine.start(script, options);
|
|
383
|
-
if (result.background) {
|
|
384
|
-
// The engine resolves the script's meta name for the delivery header.
|
|
385
|
-
void source;
|
|
386
|
-
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
503
|
+
return textResult(error instanceof Error ? error.message : String(error));
|
|
387
504
|
}
|
|
388
|
-
return textResult(formatStart(result), result);
|
|
389
505
|
},
|
|
390
506
|
}),
|
|
391
507
|
);
|
|
@@ -405,20 +521,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
405
521
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
406
522
|
},
|
|
407
523
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
524
|
+
try {
|
|
525
|
+
const rawAction = params.action as string;
|
|
526
|
+
const action = (rawAction === "status" ? "get" : rawAction) as
|
|
527
|
+
| "list"
|
|
528
|
+
| "get"
|
|
529
|
+
| "pause"
|
|
530
|
+
| "resume"
|
|
531
|
+
| "stop"
|
|
532
|
+
| "rm";
|
|
533
|
+
if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
|
|
534
|
+
throw new Error(`unknown workflow_control action: ${params.action}`);
|
|
535
|
+
}
|
|
536
|
+
const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
|
|
537
|
+
const workflowEngine = currentEngine();
|
|
538
|
+
const result = await workflowEngine.control(action, runId);
|
|
539
|
+
return textResult(JSON.stringify(result), result);
|
|
540
|
+
} catch (error: unknown) {
|
|
541
|
+
return textResult(error instanceof Error ? error.message : String(error));
|
|
418
542
|
}
|
|
419
|
-
const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
|
|
420
|
-
const result = await workflowEngine.control(action, runId);
|
|
421
|
-
return textResult(JSON.stringify(result), result);
|
|
422
543
|
},
|
|
423
544
|
}),
|
|
424
545
|
);
|
|
@@ -430,8 +551,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
430
551
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
431
552
|
const [subcommand, ...rest] = tokens;
|
|
432
553
|
const restArgs = rest.join(" ").trim();
|
|
554
|
+
const engineForCommand = (): WorkflowEngine | undefined => {
|
|
555
|
+
try {
|
|
556
|
+
return currentEngine();
|
|
557
|
+
} catch (error: unknown) {
|
|
558
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
559
|
+
return undefined;
|
|
560
|
+
}
|
|
561
|
+
};
|
|
433
562
|
if (!subcommand) {
|
|
434
|
-
await showWorkflowNavigator(ctx,
|
|
563
|
+
await showWorkflowNavigator(ctx, currentEngine);
|
|
435
564
|
return;
|
|
436
565
|
}
|
|
437
566
|
if (subcommand === "run") {
|
|
@@ -444,6 +573,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
444
573
|
return;
|
|
445
574
|
}
|
|
446
575
|
if (subcommand === "status") {
|
|
576
|
+
const workflowEngine = engineForCommand();
|
|
577
|
+
if (!workflowEngine) return;
|
|
447
578
|
if (restArgs) {
|
|
448
579
|
const run = workflowEngine.getRun(restArgs);
|
|
449
580
|
if (!run) {
|
|
@@ -463,6 +594,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
463
594
|
return;
|
|
464
595
|
}
|
|
465
596
|
if (subcommand === "watch") {
|
|
597
|
+
const workflowEngine = engineForCommand();
|
|
598
|
+
if (!workflowEngine) return;
|
|
466
599
|
const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
|
|
467
600
|
if (!runId) {
|
|
468
601
|
ctx.ui.notify("No workflow runs to watch.", "warning");
|
|
@@ -473,7 +606,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
473
606
|
return;
|
|
474
607
|
}
|
|
475
608
|
if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
|
|
476
|
-
const
|
|
609
|
+
const listedEngine = engineForCommand();
|
|
610
|
+
if (!listedEngine) return;
|
|
611
|
+
const firstListed = listedEngine.list()[0]?.runId;
|
|
477
612
|
const runId = resolveControlTarget(
|
|
478
613
|
subcommand,
|
|
479
614
|
restArgs,
|
|
@@ -490,6 +625,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
490
625
|
return;
|
|
491
626
|
}
|
|
492
627
|
try {
|
|
628
|
+
const workflowEngine = engineForCommand();
|
|
629
|
+
if (!workflowEngine) return;
|
|
493
630
|
await workflowEngine.control(subcommand, runId);
|
|
494
631
|
if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
|
|
495
632
|
ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
|
|
@@ -504,6 +641,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
504
641
|
ctx.ui.notify("/workflows save <name> [runId]", "warning");
|
|
505
642
|
return;
|
|
506
643
|
}
|
|
644
|
+
const workflowEngine = engineForCommand();
|
|
645
|
+
if (!workflowEngine) return;
|
|
507
646
|
// Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
|
|
508
647
|
const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
|
|
509
648
|
if (!runId) {
|
|
@@ -582,8 +721,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
582
721
|
// is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
|
|
583
722
|
// and it never opens UI. A user asking a question about workflows still gets
|
|
584
723
|
// an ordinary answer.
|
|
585
|
-
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
586
|
-
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
587
724
|
const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
|
|
588
725
|
const persistTriggerSettings = (): void => {
|
|
589
726
|
workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
|
|
@@ -708,7 +845,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
708
845
|
// unregister or replace a command, so clobbering one is permanent.
|
|
709
846
|
const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
710
847
|
for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
|
|
711
|
-
if (claimedCommands.has(name)) continue;
|
|
848
|
+
if (claimedCommands.has(name) || registeredWorkflowCommands.has(name)) continue;
|
|
712
849
|
const descriptor = BUILTIN_WORKFLOWS[name];
|
|
713
850
|
pi.registerCommand(name, {
|
|
714
851
|
description: `Run the ${name} workflow`,
|
|
@@ -736,54 +873,33 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
736
873
|
args: scriptArgs,
|
|
737
874
|
background: true,
|
|
738
875
|
toolset: descriptor.toolset,
|
|
876
|
+
shippedScript: true,
|
|
739
877
|
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
740
|
-
loadSavedWorkflow:
|
|
741
|
-
loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script,
|
|
878
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
742
879
|
};
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
880
|
+
try {
|
|
881
|
+
const workflowEngine = currentEngine();
|
|
882
|
+
const result = await workflowEngine.start(descriptor.script, options);
|
|
883
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
884
|
+
ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
|
|
885
|
+
} catch (error: unknown) {
|
|
886
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
887
|
+
}
|
|
746
888
|
},
|
|
747
889
|
});
|
|
890
|
+
registeredWorkflowCommands.add(name);
|
|
748
891
|
}
|
|
749
892
|
|
|
750
|
-
// Live progress widget
|
|
751
|
-
//
|
|
752
|
-
const refreshWidget = () => {
|
|
753
|
-
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
754
|
-
if (!tui?.setWidget) return;
|
|
755
|
-
const runs = workflowEngine
|
|
756
|
-
.list()
|
|
757
|
-
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
758
|
-
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
759
|
-
const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
760
|
-
tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
|
|
761
|
-
if (lines.length > 0 && !widgetTimer) {
|
|
762
|
-
widgetTimer = setInterval(() => {
|
|
763
|
-
if (!tui?.setWidget) return;
|
|
764
|
-
const current = workflowEngine
|
|
765
|
-
.list()
|
|
766
|
-
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
767
|
-
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
768
|
-
const next = liveWidgetLines(current, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
769
|
-
tui.setWidget("pi-workflows", next.length > 0 ? next : undefined);
|
|
770
|
-
if (next.length === 0 && widgetTimer) {
|
|
771
|
-
clearInterval(widgetTimer);
|
|
772
|
-
widgetTimer = undefined;
|
|
773
|
-
}
|
|
774
|
-
}, 2_000);
|
|
775
|
-
} else if (lines.length === 0 && widgetTimer) {
|
|
776
|
-
clearInterval(widgetTimer);
|
|
777
|
-
widgetTimer = undefined;
|
|
778
|
-
}
|
|
779
|
-
};
|
|
780
|
-
// Refresh after any control action and on run settlement.
|
|
781
|
-
const notifyRunChanged = () => refreshWidget();
|
|
782
|
-
workflowEngine.onRunSettled = notifyRunChanged;
|
|
783
|
-
void notifyRunChanged;
|
|
784
|
-
|
|
893
|
+
// Live progress widget refreshes through the current engine, so its timer
|
|
894
|
+
// and settlement callback remain safe across session replacement.
|
|
785
895
|
const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
786
896
|
if (!branchQuiesce) {
|
|
897
|
+
let workflowEngine: WorkflowEngine;
|
|
898
|
+
try {
|
|
899
|
+
workflowEngine = currentEngine();
|
|
900
|
+
} catch {
|
|
901
|
+
return Promise.resolve({ settled: true, pending: [] });
|
|
902
|
+
}
|
|
787
903
|
branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
|
|
788
904
|
if (!result.settled) {
|
|
789
905
|
console.warn(
|
|
@@ -796,7 +912,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
796
912
|
return branchQuiesce;
|
|
797
913
|
};
|
|
798
914
|
|
|
799
|
-
|
|
915
|
+
pi.events.on("subagents:session_before_tree", () => {
|
|
800
916
|
void beginBranchQuiesce();
|
|
801
917
|
});
|
|
802
918
|
|
|
@@ -810,6 +926,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
810
926
|
} catch {
|
|
811
927
|
return;
|
|
812
928
|
}
|
|
929
|
+
let workflowEngine: WorkflowEngine;
|
|
930
|
+
try {
|
|
931
|
+
workflowEngine = currentEngine();
|
|
932
|
+
} catch {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
813
935
|
workflowEngine.suspendLifecycle();
|
|
814
936
|
try {
|
|
815
937
|
workflowEngine.restore(entries);
|
|
@@ -894,18 +1016,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
894
1016
|
// while the graph validates dependencies before dispatching any workers.
|
|
895
1017
|
const script = buildAdHocWorkflowScript(prompt);
|
|
896
1018
|
try {
|
|
897
|
-
|
|
898
|
-
const result = await
|
|
899
|
-
|
|
1019
|
+
const workflowEngine = currentEngine();
|
|
1020
|
+
const result = await workflowEngine.start(script, {
|
|
1021
|
+
background: true,
|
|
1022
|
+
// We wrote this script, tiers included, so its tier names are a
|
|
1023
|
+
// preference against whatever catalogue this host defines.
|
|
1024
|
+
shippedScript: true,
|
|
1025
|
+
mainModel: ctx.model?.id,
|
|
1026
|
+
});
|
|
1027
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
900
1028
|
return result.runId;
|
|
901
1029
|
} catch (error: unknown) {
|
|
902
|
-
ctx.ui.notify(
|
|
1030
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
903
1031
|
return undefined;
|
|
904
1032
|
}
|
|
905
1033
|
};
|
|
906
1034
|
|
|
907
1035
|
const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
|
|
908
|
-
const state =
|
|
1036
|
+
const state = currentEngine().getState(runId ?? "");
|
|
909
1037
|
if (!state) {
|
|
910
1038
|
throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
|
|
911
1039
|
}
|
|
@@ -919,6 +1047,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
919
1047
|
sessionCwd = ctx.cwd ?? process.cwd();
|
|
920
1048
|
workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
921
1049
|
effortLevel = workflowSettings.effort;
|
|
1050
|
+
triggerKeyword = workflowSettings.keywordTriggerWord;
|
|
1051
|
+
keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
922
1052
|
let initialEntries: SessionEntryLike[];
|
|
923
1053
|
try {
|
|
924
1054
|
initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
@@ -936,6 +1066,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
936
1066
|
|
|
937
1067
|
protocolAbortController = new AbortController();
|
|
938
1068
|
const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
|
|
1069
|
+
protocolError = undefined;
|
|
1070
|
+
protocolProbe = () =>
|
|
1071
|
+
client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
|
|
1072
|
+
protocolCheck = trackProbe(protocolProbe());
|
|
1073
|
+
void protocolCheck.catch((error: unknown) => {
|
|
1074
|
+
if (generation !== lifecycleGeneration) return;
|
|
1075
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1076
|
+
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
1077
|
+
});
|
|
939
1078
|
|
|
940
1079
|
let recoveredEngine: WorkflowEngine | undefined;
|
|
941
1080
|
let recoveryError: unknown;
|
|
@@ -955,6 +1094,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
955
1094
|
return [];
|
|
956
1095
|
}
|
|
957
1096
|
},
|
|
1097
|
+
awaitProtocol,
|
|
958
1098
|
);
|
|
959
1099
|
try {
|
|
960
1100
|
const entries =
|
|
@@ -1036,36 +1176,39 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1036
1176
|
// name clashes first — never overwrite another extension's command.
|
|
1037
1177
|
const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
1038
1178
|
for (const savedName of listSavedWorkflows(sessionCwd)) {
|
|
1039
|
-
if (existingCommands.has(savedName)) continue;
|
|
1040
|
-
|
|
1041
|
-
if (!script) continue;
|
|
1179
|
+
if (existingCommands.has(savedName) || registeredWorkflowCommands.has(savedName)) continue;
|
|
1180
|
+
if (!loadSavedWorkflow(savedName, sessionCwd)) continue;
|
|
1042
1181
|
pi.registerCommand(savedName, {
|
|
1043
1182
|
description: `Run the saved workflow ${savedName}`,
|
|
1044
1183
|
handler: async (args: string, commandCtx: ExtensionCommandContext) => {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1184
|
+
try {
|
|
1185
|
+
const workflowEngine = currentEngine();
|
|
1186
|
+
const script = loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script;
|
|
1187
|
+
if (!script) throw new Error(`Saved workflow "${savedName}" is no longer available.`);
|
|
1188
|
+
const options: ScriptStartOptions = {
|
|
1189
|
+
args: args.trim() ? { prompt: args.trim() } : undefined,
|
|
1190
|
+
background: true,
|
|
1191
|
+
mainModel: commandCtx?.model?.id,
|
|
1192
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
1193
|
+
};
|
|
1194
|
+
const result = await workflowEngine.start(script, options);
|
|
1195
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1196
|
+
commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
|
|
1197
|
+
} catch (error: unknown) {
|
|
1198
|
+
commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1199
|
+
}
|
|
1054
1200
|
},
|
|
1055
1201
|
});
|
|
1202
|
+
registeredWorkflowCommands.add(savedName);
|
|
1056
1203
|
}
|
|
1057
|
-
|
|
1058
|
-
protocolError = undefined;
|
|
1059
|
-
protocolCheck = client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
|
|
1060
|
-
void protocolCheck.catch((error: unknown) => {
|
|
1061
|
-
if (generation !== lifecycleGeneration) return;
|
|
1062
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
1063
|
-
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
1064
|
-
});
|
|
1065
1204
|
});
|
|
1066
1205
|
|
|
1067
1206
|
pi.on("session_shutdown", async () => {
|
|
1068
1207
|
lifecycleGeneration += 1;
|
|
1208
|
+
// Invalidate all long-lived command/tool/widget closures before awaiting
|
|
1209
|
+
// engine quiescence; the old session must not accept another run.
|
|
1210
|
+
active = false;
|
|
1211
|
+
clearWidget();
|
|
1069
1212
|
if (widgetTimer) {
|
|
1070
1213
|
clearInterval(widgetTimer);
|
|
1071
1214
|
widgetTimer = undefined;
|
|
@@ -1076,13 +1219,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1076
1219
|
}
|
|
1077
1220
|
protocolAbortController?.abort();
|
|
1078
1221
|
protocolAbortController = undefined;
|
|
1079
|
-
unsubscribeTreeSignal?.();
|
|
1080
|
-
unsubscribeTreeSignal = undefined;
|
|
1081
1222
|
closingEngine?.dispose();
|
|
1082
1223
|
engine = undefined;
|
|
1083
|
-
active = false;
|
|
1084
1224
|
branchQuiesce = undefined;
|
|
1085
|
-
protocolCheck =
|
|
1225
|
+
protocolCheck = undefined;
|
|
1226
|
+
protocolProbe = undefined;
|
|
1227
|
+
protocolInFlight = undefined;
|
|
1086
1228
|
protocolError = undefined;
|
|
1087
1229
|
deliverResult = undefined;
|
|
1088
1230
|
});
|