@signalridge/pi-workflows 1.4.0 → 1.6.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 +249 -0
- package/README.md +193 -15
- package/package.json +3 -3
- package/skills/workflow-authoring/SKILL.md +23 -7
- package/skills/workflow-authoring/references/capabilities.md +6 -6
- package/skills/workflow-authoring/references/capability-details.md +16 -6
- package/skills/workflow-authoring/references/debugging.md +2 -2
- package/skills/workflow-authoring/references/quality-helpers.md +2 -2
- package/skills/workflow-authoring/references/registry-ownership.md +6 -4
- package/skills/workflow-authoring/references/review.md +6 -2
- package/skills/workflow-authoring/references/runtime.md +3 -3
- package/skills/workflow-authoring/references/specialized-helpers.md +1 -1
- 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 +28 -6
- package/src/engine.ts +248 -58
- package/src/index.ts +457 -173
- package/src/journal.ts +278 -253
- package/src/rpc-client.ts +37 -48
- package/src/runtime.ts +429 -54
- package/src/strengths.ts +93 -0
- package/src/ui.ts +29 -4
- package/src/workflow-settings.ts +95 -1
package/src/index.ts
CHANGED
|
@@ -8,10 +8,27 @@ 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";
|
|
18
|
+
import {
|
|
19
|
+
defaultStrengthTable,
|
|
20
|
+
isWorkflowStrength,
|
|
21
|
+
WORKFLOW_STRENGTH_LIST,
|
|
22
|
+
WORKFLOW_STRENGTHS,
|
|
23
|
+
type WorkflowStrengthTable,
|
|
24
|
+
} from "./strengths.js";
|
|
13
25
|
import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
|
|
14
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
loadProjectWorkflowSettings,
|
|
28
|
+
loadWorkflowSettings,
|
|
29
|
+
saveWorkflowSettings,
|
|
30
|
+
type WorkflowSettings,
|
|
31
|
+
} from "./workflow-settings.js";
|
|
15
32
|
|
|
16
33
|
const WorkflowToolSchema = Type.Object(
|
|
17
34
|
{
|
|
@@ -124,6 +141,7 @@ export function buildAdHocWorkflowScript(prompt: string): string {
|
|
|
124
141
|
const originalPrompt = ${JSON.stringify(prompt)};
|
|
125
142
|
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
143
|
label: "planner",
|
|
144
|
+
strength: "medium",
|
|
127
145
|
schema: {
|
|
128
146
|
type: "object",
|
|
129
147
|
properties: {
|
|
@@ -152,7 +170,7 @@ const graph = await orchestrate(plan.tasks.map((task) => ({
|
|
|
152
170
|
id: task.id,
|
|
153
171
|
description: task.description,
|
|
154
172
|
dependsOn: task.dependsOn || [],
|
|
155
|
-
run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id }),
|
|
173
|
+
run: ({ results, statuses }) => agent(task.prompt + "\\n\\nDependency results (null means unavailable):\\n" + JSON.stringify({ results, statuses }), { label: task.id, strength: "low" }),
|
|
156
174
|
})), { onError: "continue" });
|
|
157
175
|
const SYNTHESIS_CONTEXT_LIMIT = 78000;
|
|
158
176
|
function renderContextValue(value) {
|
|
@@ -226,21 +244,61 @@ while (synthesisJson.length > SYNTHESIS_CONTEXT_LIMIT && (valuePreviewBudget > 1
|
|
|
226
244
|
synthesisJson = JSON.stringify(synthesisContext);
|
|
227
245
|
}
|
|
228
246
|
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" });`;
|
|
247
|
+
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", strength: "medium" });`;
|
|
230
248
|
}
|
|
231
249
|
|
|
232
250
|
export default function piWorkflows(pi: ExtensionAPI): void {
|
|
233
251
|
let engine: WorkflowEngine | undefined;
|
|
234
252
|
let protocolError: string | undefined;
|
|
235
|
-
let protocolCheck: Promise<
|
|
253
|
+
let protocolCheck: Promise<ManagedProtocolCheck> | undefined;
|
|
254
|
+
let protocolProbe: (() => Promise<ManagedProtocolCheck>) | undefined;
|
|
255
|
+
let protocolInFlight: Promise<ManagedProtocolCheck> | undefined;
|
|
256
|
+
/**
|
|
257
|
+
* Track one in-flight probe so concurrent starts share it, and release it the
|
|
258
|
+
* moment it settles.
|
|
259
|
+
*
|
|
260
|
+
* Releasing matters: the catalogue is read live on the peer's side, so the
|
|
261
|
+
* next start or resume must issue a *new* ping. Holding a settled promise
|
|
262
|
+
* here would pin every later run to the routing policy as it stood at session
|
|
263
|
+
* start, and a tier defined since then would be rejected pre-dispatch as
|
|
264
|
+
* unknown.
|
|
265
|
+
*/
|
|
266
|
+
const trackProbe = (check: Promise<ManagedProtocolCheck>): Promise<ManagedProtocolCheck> => {
|
|
267
|
+
protocolInFlight = check;
|
|
268
|
+
const release = () => {
|
|
269
|
+
if (protocolInFlight === check) protocolInFlight = undefined;
|
|
270
|
+
};
|
|
271
|
+
void check.then(release, release);
|
|
272
|
+
return check;
|
|
273
|
+
};
|
|
274
|
+
const awaitProtocol = async (): Promise<ManagedProtocolCheck> => {
|
|
275
|
+
let check = protocolInFlight;
|
|
276
|
+
if (!check) {
|
|
277
|
+
if (protocolProbe) protocolError = undefined;
|
|
278
|
+
check = protocolProbe?.() ?? protocolCheck;
|
|
279
|
+
if (!check) throw new Error(`${PROTOCOL_DIAGNOSTIC} Diagnostic: no active protocol probe`);
|
|
280
|
+
trackProbe(check);
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const result = await check;
|
|
284
|
+
protocolError = undefined;
|
|
285
|
+
return result;
|
|
286
|
+
} catch (error: unknown) {
|
|
287
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
288
|
+
throw new Error(protocolError ?? `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
236
291
|
let active = false;
|
|
237
|
-
let
|
|
292
|
+
let surfaceRegistered = false;
|
|
293
|
+
const registeredWorkflowCommands = new Set<string>();
|
|
238
294
|
let branchQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
239
295
|
let lifecycleGeneration = 0;
|
|
240
296
|
let protocolAbortController: AbortController | undefined;
|
|
241
297
|
let sessionCwd = process.cwd();
|
|
242
298
|
let workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
243
299
|
let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
|
|
300
|
+
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
301
|
+
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
244
302
|
/** Per-session delivery endpoint for background-run results (A10 fail-closed). */
|
|
245
303
|
let deliverResult: ((text: string, details?: unknown) => void) | undefined;
|
|
246
304
|
/** Pending deliveries for runs that settled before a session endpoint was bound (A10). */
|
|
@@ -252,10 +310,42 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
252
310
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
253
311
|
|
|
254
312
|
const currentEngine = (): WorkflowEngine => {
|
|
255
|
-
if (!
|
|
313
|
+
if (!active || !engine || engine.isDisposed())
|
|
314
|
+
throw new Error("pi-workflows is not active in this session context");
|
|
256
315
|
return engine;
|
|
257
316
|
};
|
|
258
317
|
|
|
318
|
+
const clearWidget = (): void => {
|
|
319
|
+
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
320
|
+
tui?.setWidget?.("pi-workflows", undefined);
|
|
321
|
+
};
|
|
322
|
+
const refreshWidget = (): void => {
|
|
323
|
+
const tui = (pi as unknown as { ui?: { setWidget?: (key: string, content: string[] | undefined) => void } }).ui;
|
|
324
|
+
if (!tui?.setWidget) return;
|
|
325
|
+
const workflowEngine = engine;
|
|
326
|
+
if (!active || !workflowEngine || workflowEngine.isDisposed()) {
|
|
327
|
+
tui.setWidget("pi-workflows", undefined);
|
|
328
|
+
if (widgetTimer) {
|
|
329
|
+
clearInterval(widgetTimer);
|
|
330
|
+
widgetTimer = undefined;
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const runs = workflowEngine
|
|
335
|
+
.list()
|
|
336
|
+
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
337
|
+
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
338
|
+
const lines = liveWidgetLines(runs, workflowSettings.progressMode, workflowSettings.maxAgentsShown);
|
|
339
|
+
tui.setWidget("pi-workflows", lines.length > 0 ? lines : undefined);
|
|
340
|
+
if (lines.length > 0 && !widgetTimer) {
|
|
341
|
+
widgetTimer = setInterval(refreshWidget, 2_000);
|
|
342
|
+
} else if (lines.length === 0 && widgetTimer) {
|
|
343
|
+
clearInterval(widgetTimer);
|
|
344
|
+
widgetTimer = undefined;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
const notifyRunChanged = (): void => refreshWidget();
|
|
348
|
+
|
|
259
349
|
const branchEntries = (): SessionEntryLike[] => {
|
|
260
350
|
const ctx = (pi as unknown as { currentSessionManager?: { getBranch?: () => unknown } }).currentSessionManager;
|
|
261
351
|
try {
|
|
@@ -267,6 +357,26 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
267
357
|
|
|
268
358
|
const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
|
|
269
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Persist one settings change into this project's own file.
|
|
362
|
+
*
|
|
363
|
+
* `workflowSettings` is the merged view — defaults, then global, then project
|
|
364
|
+
* — while `saveWorkflowSettings` replaces the project file with whatever it
|
|
365
|
+
* is handed. Handing it the merged view would copy every global value and
|
|
366
|
+
* every default into the project on the first edit, and the project would
|
|
367
|
+
* stop tracking the global file from then on. So the patch lands on project
|
|
368
|
+
* scope and the merged view is re-derived from what actually landed, which is
|
|
369
|
+
* also what makes it safe to report the result rather than the intent.
|
|
370
|
+
*/
|
|
371
|
+
const persistWorkflowSetting = (patch: WorkflowSettings): void => {
|
|
372
|
+
saveWorkflowSettings({ ...loadProjectWorkflowSettings(sessionCwd), ...patch }, sessionCwd);
|
|
373
|
+
workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/** Resolve a nested `workflow(name)` reference; a saved file shadows a built-in. */
|
|
377
|
+
const resolveNestedWorkflow = (name: string): string | undefined =>
|
|
378
|
+
loadSavedWorkflow(name, sessionCwd) ?? BUILTIN_WORKFLOWS[name]?.script;
|
|
379
|
+
|
|
270
380
|
const resolveScript = (
|
|
271
381
|
script: string | undefined,
|
|
272
382
|
name: string | undefined,
|
|
@@ -295,6 +405,17 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
295
405
|
if (active) return;
|
|
296
406
|
active = true;
|
|
297
407
|
const workflowEngine = currentEngine();
|
|
408
|
+
workflowEngine.onRunSettled = notifyRunChanged;
|
|
409
|
+
// `control("resume")` and the provider-limit retry start executions with no
|
|
410
|
+
// caller to pass options for them, and the strength table is deliberately
|
|
411
|
+
// never frozen onto a run. Without this they would resume with no table and
|
|
412
|
+
// re-run every finished call untabled.
|
|
413
|
+
workflowEngine.strengths = () => workflowSettings.strengths;
|
|
414
|
+
// Command/tool registrations persist across session replacement, but the
|
|
415
|
+
// widget belongs to the newly restored engine and must be refreshed too.
|
|
416
|
+
refreshWidget();
|
|
417
|
+
if (surfaceRegistered) return;
|
|
418
|
+
surfaceRegistered = true;
|
|
298
419
|
|
|
299
420
|
pi.registerTool(
|
|
300
421
|
defineTool({
|
|
@@ -330,62 +451,67 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
330
451
|
},
|
|
331
452
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
332
453
|
try {
|
|
333
|
-
|
|
454
|
+
const workflowEngine = currentEngine();
|
|
455
|
+
const resolved =
|
|
456
|
+
params.resumeFromRunId && params.script === undefined && params.name === undefined
|
|
457
|
+
? (() => {
|
|
458
|
+
const prior = workflowEngine.getRun(params.resumeFromRunId);
|
|
459
|
+
if (!prior) throw new Error(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
460
|
+
// Resume replays the stored script; only the shared shape
|
|
461
|
+
// has to be satisfied here.
|
|
462
|
+
return { script: prior.script, source: "resume", toolset: prior.toolset };
|
|
463
|
+
})()
|
|
464
|
+
: resolveScript(params.script, params.name, params.args);
|
|
465
|
+
const { script, source, toolset } = resolved;
|
|
466
|
+
const options: ScriptStartOptions = {
|
|
467
|
+
args: params.args,
|
|
468
|
+
background: params.background ?? true,
|
|
469
|
+
maxAgents: params.maxAgents,
|
|
470
|
+
concurrency: params.concurrency,
|
|
471
|
+
agentRetries: params.agentRetries,
|
|
472
|
+
tokenBudget: params.tokenBudget,
|
|
473
|
+
agentTimeoutMs: params.agentTimeoutMs,
|
|
474
|
+
toolset,
|
|
475
|
+
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
476
|
+
signal,
|
|
477
|
+
mainModel: ctx?.model?.id,
|
|
478
|
+
// Foreground checkpoint confirmation only when the run is not
|
|
479
|
+
// background — background runs are headless by contract.
|
|
480
|
+
confirm:
|
|
481
|
+
params.background === false && ctx?.hasUI && ctx.mode === "tui"
|
|
482
|
+
? async (promptText, checkpointOptions) => {
|
|
483
|
+
if (checkpointOptions.kind === "input")
|
|
484
|
+
return ctx.ui.input(promptText, String(checkpointOptions.default ?? ""));
|
|
485
|
+
if (checkpointOptions.kind === "select")
|
|
486
|
+
return ctx.ui.select(promptText, checkpointOptions.choices ?? []);
|
|
487
|
+
return ctx.ui.confirm(promptText, promptText);
|
|
488
|
+
}
|
|
489
|
+
: undefined,
|
|
490
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
491
|
+
strengths: workflowSettings.strengths,
|
|
492
|
+
};
|
|
493
|
+
if (params.resumeFromRunId) {
|
|
494
|
+
const replacement = params.script !== undefined || params.name !== undefined ? script : undefined;
|
|
495
|
+
const resumed = await workflowEngine.resume(
|
|
496
|
+
params.resumeFromRunId,
|
|
497
|
+
branchEntries(),
|
|
498
|
+
options,
|
|
499
|
+
replacement,
|
|
500
|
+
);
|
|
501
|
+
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
502
|
+
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
503
|
+
return textResult(formatStart(resumed), resumed);
|
|
504
|
+
}
|
|
505
|
+
const result = await workflowEngine.start(script, options);
|
|
506
|
+
if (result.background) {
|
|
507
|
+
// The engine resolves the script's meta name for the delivery header.
|
|
508
|
+
void source;
|
|
509
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
510
|
+
}
|
|
511
|
+
return textResult(formatStart(result), result);
|
|
334
512
|
} catch (error: unknown) {
|
|
335
|
-
return textResult(
|
|
336
|
-
protocolError ??
|
|
337
|
-
`${PROTOCOL_DIAGNOSTIC} Diagnostic: ${error instanceof Error ? error.message : String(error)}`,
|
|
338
|
-
);
|
|
513
|
+
return textResult(error instanceof Error ? error.message : String(error));
|
|
339
514
|
}
|
|
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);
|
|
387
|
-
}
|
|
388
|
-
return textResult(formatStart(result), result);
|
|
389
515
|
},
|
|
390
516
|
}),
|
|
391
517
|
);
|
|
@@ -405,33 +531,46 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
405
531
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
406
532
|
},
|
|
407
533
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
534
|
+
try {
|
|
535
|
+
const rawAction = params.action as string;
|
|
536
|
+
const action = (rawAction === "status" ? "get" : rawAction) as
|
|
537
|
+
| "list"
|
|
538
|
+
| "get"
|
|
539
|
+
| "pause"
|
|
540
|
+
| "resume"
|
|
541
|
+
| "stop"
|
|
542
|
+
| "rm";
|
|
543
|
+
if (!["list", "get", "pause", "resume", "stop", "rm"].includes(action)) {
|
|
544
|
+
throw new Error(`unknown workflow_control action: ${params.action}`);
|
|
545
|
+
}
|
|
546
|
+
const runId = normalizeControlRunId(params as unknown as Record<string, unknown>);
|
|
547
|
+
const workflowEngine = currentEngine();
|
|
548
|
+
const result = await workflowEngine.control(action, runId);
|
|
549
|
+
return textResult(JSON.stringify(result), result);
|
|
550
|
+
} catch (error: unknown) {
|
|
551
|
+
return textResult(error instanceof Error ? error.message : String(error));
|
|
418
552
|
}
|
|
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
553
|
},
|
|
423
554
|
}),
|
|
424
555
|
);
|
|
425
556
|
|
|
426
557
|
// /workflows: navigator + run/status/watch/stop/pause/resume/rm/save subcommands
|
|
427
558
|
pi.registerCommand("workflows", {
|
|
428
|
-
description: "Workflow runs:
|
|
559
|
+
description: "Workflow runs: navigator, or run/status/watch/stop/pause/resume/rm/save/strength",
|
|
429
560
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
430
561
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
431
562
|
const [subcommand, ...rest] = tokens;
|
|
432
563
|
const restArgs = rest.join(" ").trim();
|
|
564
|
+
const engineForCommand = (): WorkflowEngine | undefined => {
|
|
565
|
+
try {
|
|
566
|
+
return currentEngine();
|
|
567
|
+
} catch (error: unknown) {
|
|
568
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
433
572
|
if (!subcommand) {
|
|
434
|
-
await showWorkflowNavigator(ctx,
|
|
573
|
+
await showWorkflowNavigator(ctx, currentEngine);
|
|
435
574
|
return;
|
|
436
575
|
}
|
|
437
576
|
if (subcommand === "run") {
|
|
@@ -444,6 +583,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
444
583
|
return;
|
|
445
584
|
}
|
|
446
585
|
if (subcommand === "status") {
|
|
586
|
+
const workflowEngine = engineForCommand();
|
|
587
|
+
if (!workflowEngine) return;
|
|
447
588
|
if (restArgs) {
|
|
448
589
|
const run = workflowEngine.getRun(restArgs);
|
|
449
590
|
if (!run) {
|
|
@@ -463,6 +604,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
463
604
|
return;
|
|
464
605
|
}
|
|
465
606
|
if (subcommand === "watch") {
|
|
607
|
+
const workflowEngine = engineForCommand();
|
|
608
|
+
if (!workflowEngine) return;
|
|
466
609
|
const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
|
|
467
610
|
if (!runId) {
|
|
468
611
|
ctx.ui.notify("No workflow runs to watch.", "warning");
|
|
@@ -473,7 +616,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
473
616
|
return;
|
|
474
617
|
}
|
|
475
618
|
if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
|
|
476
|
-
const
|
|
619
|
+
const listedEngine = engineForCommand();
|
|
620
|
+
if (!listedEngine) return;
|
|
621
|
+
const firstListed = listedEngine.list()[0]?.runId;
|
|
477
622
|
const runId = resolveControlTarget(
|
|
478
623
|
subcommand,
|
|
479
624
|
restArgs,
|
|
@@ -490,6 +635,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
490
635
|
return;
|
|
491
636
|
}
|
|
492
637
|
try {
|
|
638
|
+
const workflowEngine = engineForCommand();
|
|
639
|
+
if (!workflowEngine) return;
|
|
493
640
|
await workflowEngine.control(subcommand, runId);
|
|
494
641
|
if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
|
|
495
642
|
ctx.ui.notify(`Workflow ${subcommand} requested: ${runId}`, "info");
|
|
@@ -504,6 +651,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
504
651
|
ctx.ui.notify("/workflows save <name> [runId]", "warning");
|
|
505
652
|
return;
|
|
506
653
|
}
|
|
654
|
+
const workflowEngine = engineForCommand();
|
|
655
|
+
if (!workflowEngine) return;
|
|
507
656
|
// Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
|
|
508
657
|
const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
|
|
509
658
|
if (!runId) {
|
|
@@ -517,23 +666,158 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
517
666
|
}
|
|
518
667
|
return;
|
|
519
668
|
}
|
|
669
|
+
if (subcommand === "strength") {
|
|
670
|
+
// The Agent tier catalogue is owned by pi-subagents and still is (A8:
|
|
671
|
+
// no second tier config file). What this edits is narrower and is
|
|
672
|
+
// genuinely ours: which Agent tier each workflow strength runs on.
|
|
673
|
+
//
|
|
674
|
+
// A subcommand rather than a command of its own, because the reason
|
|
675
|
+
// to surface it at all is the three-part view below — two lines of
|
|
676
|
+
// which come from this package and one from the other — and that is
|
|
677
|
+
// a page to read, not a mode to be in.
|
|
678
|
+
//
|
|
679
|
+
// The catalogue lives on the managed peer, so it can legitimately be
|
|
680
|
+
// unreachable here. Status still has its other two parts, and a set
|
|
681
|
+
// made against a table that already exists is stored unvalidated: the
|
|
682
|
+
// run-start check reports a tier this host does not define and drops
|
|
683
|
+
// that entry, which costs the redirect rather than the run. A set made
|
|
684
|
+
// with no table at all is refused instead — see below, where the seed
|
|
685
|
+
// is read.
|
|
686
|
+
const catalogue = await (async (): Promise<string[] | undefined> => {
|
|
687
|
+
try {
|
|
688
|
+
return Object.keys((await awaitProtocol()).routingPolicy.profiles).sort();
|
|
689
|
+
} catch {
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
})();
|
|
693
|
+
const knownTiers = catalogue ? new Set(catalogue) : undefined;
|
|
694
|
+
// What a run would resolve, by the same rule the runtime uses: a
|
|
695
|
+
// configured table if there is one, else the shipped default. Shown
|
|
696
|
+
// rather than the configured table alone, because an unconfigured
|
|
697
|
+
// machine is not an unrouted one and a row reading "(unset)" there
|
|
698
|
+
// would describe something other than what the next run does.
|
|
699
|
+
const effective = (): { table: WorkflowStrengthTable; shipped: boolean } => {
|
|
700
|
+
const configured = workflowSettings.strengths;
|
|
701
|
+
return configured === undefined
|
|
702
|
+
? { table: defaultStrengthTable(knownTiers), shipped: true }
|
|
703
|
+
: { table: configured, shipped: false };
|
|
704
|
+
};
|
|
705
|
+
const describe = (): string => {
|
|
706
|
+
const { table, shipped } = effective();
|
|
707
|
+
const rows = WORKFLOW_STRENGTHS.map((name) => {
|
|
708
|
+
// The default is derived from the catalogue, so without one there
|
|
709
|
+
// is nothing honest to print: the run will still route these, and
|
|
710
|
+
// "(unset)" would claim it will not.
|
|
711
|
+
if (shipped && knownTiers === undefined) return ` ${name} → (default; needs the tier catalogue)`;
|
|
712
|
+
if (table[name] === undefined) return ` ${name} → (unset; the agent's own default)`;
|
|
713
|
+
return ` ${name} → ${table[name]}${shipped ? " (default)" : ""}`;
|
|
714
|
+
});
|
|
715
|
+
// The shipped default is identity, so on a stock install workflow
|
|
716
|
+
// work rides the same profiles every ordinary spawn does — the
|
|
717
|
+
// coupling this indirection exists to let you break, but does not
|
|
718
|
+
// break on its own. Saying so here is the only place the two steps
|
|
719
|
+
// out of it are visible together.
|
|
720
|
+
const decouple = shipped
|
|
721
|
+
? "\nThis is the shipped default: each strength on the tier of the same name, so " +
|
|
722
|
+
"workflow work shares the tiers your ordinary spawns use (Explore among them). " +
|
|
723
|
+
"To price workflow work on its own, define a tier in /agents → Model tiers, then " +
|
|
724
|
+
"point a strength at it here."
|
|
725
|
+
: "";
|
|
726
|
+
return (
|
|
727
|
+
`Workflow strengths and the Agent tier each runs on:\n${rows.join("\n")}\n` +
|
|
728
|
+
`Tiers this host defines: ${catalogue?.join(", ") ?? "(unavailable)"}\n` +
|
|
729
|
+
"Edits apply to this project; for a machine-wide table edit " +
|
|
730
|
+
"<agent dir>/workflows/settings.json. The tiers themselves live in " +
|
|
731
|
+
"/agents → Model tiers; this only says which one a strength uses." +
|
|
732
|
+
decouple
|
|
733
|
+
);
|
|
734
|
+
};
|
|
735
|
+
const [strength, target] = rest;
|
|
736
|
+
if (strength === undefined) {
|
|
737
|
+
ctx.ui.notify(describe(), "info");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!isWorkflowStrength(strength)) {
|
|
741
|
+
ctx.ui.notify(`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`, "warning");
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (target === undefined || rest.length !== 2) {
|
|
745
|
+
ctx.ui.notify(`/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// `off` reads as "unset" unless the host really defines a tier by
|
|
749
|
+
// that name, in which case the user's own catalogue outranks our
|
|
750
|
+
// keyword — a tier you cannot point at is worse than a clumsier way
|
|
751
|
+
// to clear one.
|
|
752
|
+
const unset = target === "off" && catalogue?.includes("off") !== true;
|
|
753
|
+
if (!unset && catalogue && !catalogue.includes(target)) {
|
|
754
|
+
ctx.ui.notify(
|
|
755
|
+
`"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
|
|
756
|
+
"Define it in /agents → Model tiers first.",
|
|
757
|
+
"warning",
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const { table: current, shipped } = effective();
|
|
762
|
+
if (shipped && knownTiers === undefined) {
|
|
763
|
+
// Seeding from a default we cannot compute would write a one-entry
|
|
764
|
+
// table, and this key replaces rather than merges — so the other two
|
|
765
|
+
// strengths would go from routed to unmapped as a side effect of
|
|
766
|
+
// setting this one. Refusing costs a retry; writing costs a run.
|
|
767
|
+
ctx.ui.notify(
|
|
768
|
+
"The tier catalogue is unavailable, so the current table cannot be read. " +
|
|
769
|
+
"Retry once the managed peer is reachable.",
|
|
770
|
+
"warning",
|
|
771
|
+
);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
// Seeded from the table a run would use, not from this project's file.
|
|
775
|
+
// Every level of this key replaces the one below rather than merging
|
|
776
|
+
// into it, so a patch built on project scope would silently drop
|
|
777
|
+
// whatever the level below contributed: adding one entry would discard
|
|
778
|
+
// an inherited global table, and `off` against an entry this project
|
|
779
|
+
// never wrote would write nothing and leave the mapping standing. The
|
|
780
|
+
// seed does freeze the current table into the project, which is what a
|
|
781
|
+
// replace-wholesale key costs — the entries a partial file left out
|
|
782
|
+
// would be unmapped, not inherited.
|
|
783
|
+
const { [strength]: _cleared, ...others } = current;
|
|
784
|
+
try {
|
|
785
|
+
persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
|
|
786
|
+
} catch (error: unknown) {
|
|
787
|
+
ctx.ui.notify(
|
|
788
|
+
`Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
|
|
789
|
+
"warning",
|
|
790
|
+
);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// Show the resolved result rather than echoing the write. The
|
|
794
|
+
// settings layer can drop what it was handed, and `off` against the
|
|
795
|
+
// last remaining entry stores an explicit empty table — "map nothing",
|
|
796
|
+
// which is not the same as the shipped default an absent key would
|
|
797
|
+
// mean — so a message announcing the write would describe something
|
|
798
|
+
// other than what the next run will do. Runs already in flight keep
|
|
799
|
+
// what they started with; the table is read fresh at each start and
|
|
800
|
+
// resume.
|
|
801
|
+
//
|
|
802
|
+
// The one case the table alone cannot explain: the host defines a tier
|
|
803
|
+
// named `off`, so the keyword lost to it above and this mapped rather
|
|
804
|
+
// than cleared. The catalogue outranking our keyword is the right call
|
|
805
|
+
// — a tier you cannot point at is worse than a clumsier way to clear
|
|
806
|
+
// one — but a user who typed the documented keyword and got a mapping
|
|
807
|
+
// has to be told which reading won, and where the other one lives.
|
|
808
|
+
const shadowed =
|
|
809
|
+
target === "off" && !unset
|
|
810
|
+
? '\n\nNote: this host defines a tier named "off", so `off` was read as that tier rather ' +
|
|
811
|
+
"than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
|
|
812
|
+
"`strengths` object in this project's settings.json."
|
|
813
|
+
: "";
|
|
814
|
+
ctx.ui.notify(`${describe()}${shadowed}`, "info");
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
520
817
|
ctx.ui.notify(`Unknown /workflows subcommand: ${subcommand}`, "warning");
|
|
521
818
|
},
|
|
522
819
|
});
|
|
523
820
|
|
|
524
|
-
// /workflows-models: tier configuration is owned by pi-subagents; this
|
|
525
|
-
// command documents the delegation (A8: no second tier config file).
|
|
526
|
-
pi.registerCommand("workflows-models", {
|
|
527
|
-
description: "Workflow tier routing: tiers are configured in pi-subagents /agents → Model tiers",
|
|
528
|
-
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
529
|
-
ctx.ui.notify(
|
|
530
|
-
"Workflow tiers (small/medium/large) route through pi-subagents' workflow.tiers settings. " +
|
|
531
|
-
"Use /agents → Model tiers to configure them.",
|
|
532
|
-
"info",
|
|
533
|
-
);
|
|
534
|
-
},
|
|
535
|
-
});
|
|
536
|
-
|
|
537
821
|
// /workflows-progress: persistent compact/detailed live-panel settings.
|
|
538
822
|
pi.registerCommand("workflows-progress", {
|
|
539
823
|
description: "Workflow progress panel: compact|detailed|status|max <N>",
|
|
@@ -547,21 +831,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
547
831
|
);
|
|
548
832
|
return;
|
|
549
833
|
}
|
|
834
|
+
let patch: WorkflowSettings;
|
|
550
835
|
if (action === "compact" || action === "detailed") {
|
|
551
|
-
|
|
836
|
+
patch = { progressMode: action };
|
|
552
837
|
} else if (action === "max") {
|
|
553
838
|
const max = Number(tokens[1]);
|
|
554
839
|
if (!Number.isInteger(max) || max < 1 || max > 32) {
|
|
555
840
|
ctx.ui.notify("Usage: /workflows-progress max <1-32>", "warning");
|
|
556
841
|
return;
|
|
557
842
|
}
|
|
558
|
-
|
|
843
|
+
patch = { maxAgentsShown: max };
|
|
559
844
|
} else {
|
|
560
845
|
ctx.ui.notify("Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
|
|
561
846
|
return;
|
|
562
847
|
}
|
|
563
848
|
try {
|
|
564
|
-
|
|
849
|
+
persistWorkflowSetting(patch);
|
|
565
850
|
refreshWidget();
|
|
566
851
|
ctx.ui.notify(
|
|
567
852
|
`Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
|
|
@@ -582,13 +867,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
582
867
|
// is annotated to say so. Arming AUTHORIZES the tool; it never forces a run,
|
|
583
868
|
// and it never opens UI. A user asking a question about workflows still gets
|
|
584
869
|
// an ordinary answer.
|
|
585
|
-
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
586
|
-
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
587
870
|
const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
|
|
588
871
|
const persistTriggerSettings = (): void => {
|
|
589
|
-
workflowSettings = { ...workflowSettings, keywordTriggerWord: triggerKeyword, keywordTriggerEnabled };
|
|
590
872
|
try {
|
|
591
|
-
|
|
873
|
+
persistWorkflowSetting({ keywordTriggerWord: triggerKeyword, keywordTriggerEnabled });
|
|
592
874
|
} catch {
|
|
593
875
|
/* setting changes remain active in memory */
|
|
594
876
|
}
|
|
@@ -647,7 +929,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
647
929
|
if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
|
|
648
930
|
const effortDirective =
|
|
649
931
|
effortLevel === "ultra"
|
|
650
|
-
?
|
|
932
|
+
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks; give the deepest steps strength: "high".'
|
|
651
933
|
: effortLevel === "high"
|
|
652
934
|
? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
|
|
653
935
|
: "";
|
|
@@ -669,9 +951,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
669
951
|
return;
|
|
670
952
|
}
|
|
671
953
|
effortLevel = level;
|
|
672
|
-
workflowSettings = { ...workflowSettings, effort: effortLevel };
|
|
673
954
|
try {
|
|
674
|
-
|
|
955
|
+
persistWorkflowSetting({ effort: effortLevel });
|
|
675
956
|
} catch {
|
|
676
957
|
/* warning is non-fatal */
|
|
677
958
|
}
|
|
@@ -687,9 +968,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
687
968
|
description: "Ultracode: toggle exhaustive workflow effort",
|
|
688
969
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
689
970
|
effortLevel = args.trim().toLowerCase() === "off" ? "off" : "ultra";
|
|
690
|
-
workflowSettings = { ...workflowSettings, effort: effortLevel };
|
|
691
971
|
try {
|
|
692
|
-
|
|
972
|
+
persistWorkflowSetting({ effort: effortLevel });
|
|
693
973
|
} catch {
|
|
694
974
|
/* warning is non-fatal */
|
|
695
975
|
}
|
|
@@ -708,7 +988,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
708
988
|
// unregister or replace a command, so clobbering one is permanent.
|
|
709
989
|
const claimedCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
710
990
|
for (const name of Object.keys(BUILTIN_WORKFLOWS)) {
|
|
711
|
-
if (claimedCommands.has(name)) continue;
|
|
991
|
+
if (claimedCommands.has(name) || registeredWorkflowCommands.has(name)) continue;
|
|
712
992
|
const descriptor = BUILTIN_WORKFLOWS[name];
|
|
713
993
|
pi.registerCommand(name, {
|
|
714
994
|
description: `Run the ${name} workflow`,
|
|
@@ -737,53 +1017,32 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
737
1017
|
background: true,
|
|
738
1018
|
toolset: descriptor.toolset,
|
|
739
1019
|
excludeTools: [...DEFAULT_EXCLUDED_TOOLS],
|
|
740
|
-
loadSavedWorkflow:
|
|
741
|
-
|
|
1020
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
1021
|
+
strengths: workflowSettings.strengths,
|
|
742
1022
|
};
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
1023
|
+
try {
|
|
1024
|
+
const workflowEngine = currentEngine();
|
|
1025
|
+
const result = await workflowEngine.start(descriptor.script, options);
|
|
1026
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1027
|
+
ctx.ui.notify(`Workflow ${name} started in background: ${result.runId}`, "info");
|
|
1028
|
+
} catch (error: unknown) {
|
|
1029
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1030
|
+
}
|
|
746
1031
|
},
|
|
747
1032
|
});
|
|
1033
|
+
registeredWorkflowCommands.add(name);
|
|
748
1034
|
}
|
|
749
1035
|
|
|
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
|
-
|
|
1036
|
+
// Live progress widget refreshes through the current engine, so its timer
|
|
1037
|
+
// and settlement callback remain safe across session replacement.
|
|
785
1038
|
const beginBranchQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
786
1039
|
if (!branchQuiesce) {
|
|
1040
|
+
let workflowEngine: WorkflowEngine;
|
|
1041
|
+
try {
|
|
1042
|
+
workflowEngine = currentEngine();
|
|
1043
|
+
} catch {
|
|
1044
|
+
return Promise.resolve({ settled: true, pending: [] });
|
|
1045
|
+
}
|
|
787
1046
|
branchQuiesce = workflowEngine.quiesceForBranchChange().then((result) => {
|
|
788
1047
|
if (!result.settled) {
|
|
789
1048
|
console.warn(
|
|
@@ -796,7 +1055,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
796
1055
|
return branchQuiesce;
|
|
797
1056
|
};
|
|
798
1057
|
|
|
799
|
-
|
|
1058
|
+
pi.events.on("subagents:session_before_tree", () => {
|
|
800
1059
|
void beginBranchQuiesce();
|
|
801
1060
|
});
|
|
802
1061
|
|
|
@@ -810,6 +1069,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
810
1069
|
} catch {
|
|
811
1070
|
return;
|
|
812
1071
|
}
|
|
1072
|
+
let workflowEngine: WorkflowEngine;
|
|
1073
|
+
try {
|
|
1074
|
+
workflowEngine = currentEngine();
|
|
1075
|
+
} catch {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
813
1078
|
workflowEngine.suspendLifecycle();
|
|
814
1079
|
try {
|
|
815
1080
|
workflowEngine.restore(entries);
|
|
@@ -894,18 +1159,22 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
894
1159
|
// while the graph validates dependencies before dispatching any workers.
|
|
895
1160
|
const script = buildAdHocWorkflowScript(prompt);
|
|
896
1161
|
try {
|
|
897
|
-
|
|
898
|
-
const result = await
|
|
899
|
-
|
|
1162
|
+
const workflowEngine = currentEngine();
|
|
1163
|
+
const result = await workflowEngine.start(script, {
|
|
1164
|
+
background: true,
|
|
1165
|
+
strengths: workflowSettings.strengths,
|
|
1166
|
+
mainModel: ctx.model?.id,
|
|
1167
|
+
});
|
|
1168
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
900
1169
|
return result.runId;
|
|
901
1170
|
} catch (error: unknown) {
|
|
902
|
-
ctx.ui.notify(
|
|
1171
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
903
1172
|
return undefined;
|
|
904
1173
|
}
|
|
905
1174
|
};
|
|
906
1175
|
|
|
907
1176
|
const saveWorkflowFromRun = async (ctx: ExtensionCommandContext, name: string, runId?: string): Promise<void> => {
|
|
908
|
-
const state =
|
|
1177
|
+
const state = currentEngine().getState(runId ?? "");
|
|
909
1178
|
if (!state) {
|
|
910
1179
|
throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
|
|
911
1180
|
}
|
|
@@ -919,6 +1188,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
919
1188
|
sessionCwd = ctx.cwd ?? process.cwd();
|
|
920
1189
|
workflowSettings = loadWorkflowSettings(sessionCwd);
|
|
921
1190
|
effortLevel = workflowSettings.effort;
|
|
1191
|
+
triggerKeyword = workflowSettings.keywordTriggerWord;
|
|
1192
|
+
keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
922
1193
|
let initialEntries: SessionEntryLike[];
|
|
923
1194
|
try {
|
|
924
1195
|
initialEntries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
@@ -936,6 +1207,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
936
1207
|
|
|
937
1208
|
protocolAbortController = new AbortController();
|
|
938
1209
|
const client = createManagedSpawnClient(pi.events, protocolAbortController.signal, protocolAbortController.signal);
|
|
1210
|
+
protocolError = undefined;
|
|
1211
|
+
protocolProbe = () =>
|
|
1212
|
+
client.checkProtocol?.() ?? Promise.reject(new Error("managed protocol check is unavailable"));
|
|
1213
|
+
protocolCheck = trackProbe(protocolProbe());
|
|
1214
|
+
void protocolCheck.catch((error: unknown) => {
|
|
1215
|
+
if (generation !== lifecycleGeneration) return;
|
|
1216
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1217
|
+
protocolError = `${PROTOCOL_DIAGNOSTIC} Diagnostic: ${detail}`;
|
|
1218
|
+
});
|
|
939
1219
|
|
|
940
1220
|
let recoveredEngine: WorkflowEngine | undefined;
|
|
941
1221
|
let recoveryError: unknown;
|
|
@@ -955,6 +1235,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
955
1235
|
return [];
|
|
956
1236
|
}
|
|
957
1237
|
},
|
|
1238
|
+
awaitProtocol,
|
|
958
1239
|
);
|
|
959
1240
|
try {
|
|
960
1241
|
const entries =
|
|
@@ -1036,36 +1317,40 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1036
1317
|
// name clashes first — never overwrite another extension's command.
|
|
1037
1318
|
const existingCommands = new Set((pi.getCommands?.() ?? []).map((command) => command.name));
|
|
1038
1319
|
for (const savedName of listSavedWorkflows(sessionCwd)) {
|
|
1039
|
-
if (existingCommands.has(savedName)) continue;
|
|
1040
|
-
|
|
1041
|
-
if (!script) continue;
|
|
1320
|
+
if (existingCommands.has(savedName) || registeredWorkflowCommands.has(savedName)) continue;
|
|
1321
|
+
if (!loadSavedWorkflow(savedName, sessionCwd)) continue;
|
|
1042
1322
|
pi.registerCommand(savedName, {
|
|
1043
1323
|
description: `Run the saved workflow ${savedName}`,
|
|
1044
1324
|
handler: async (args: string, commandCtx: ExtensionCommandContext) => {
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1325
|
+
try {
|
|
1326
|
+
const workflowEngine = currentEngine();
|
|
1327
|
+
const script = loadSavedWorkflow(savedName, sessionCwd) ?? BUILTIN_WORKFLOWS[savedName]?.script;
|
|
1328
|
+
if (!script) throw new Error(`Saved workflow "${savedName}" is no longer available.`);
|
|
1329
|
+
const options: ScriptStartOptions = {
|
|
1330
|
+
args: args.trim() ? { prompt: args.trim() } : undefined,
|
|
1331
|
+
background: true,
|
|
1332
|
+
mainModel: commandCtx?.model?.id,
|
|
1333
|
+
loadSavedWorkflow: resolveNestedWorkflow,
|
|
1334
|
+
strengths: workflowSettings.strengths,
|
|
1335
|
+
};
|
|
1336
|
+
const result = await workflowEngine.start(script, options);
|
|
1337
|
+
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1338
|
+
commandCtx.ui.notify(`Workflow ${savedName} started in background: ${result.runId}`, "info");
|
|
1339
|
+
} catch (error: unknown) {
|
|
1340
|
+
commandCtx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
1341
|
+
}
|
|
1054
1342
|
},
|
|
1055
1343
|
});
|
|
1344
|
+
registeredWorkflowCommands.add(savedName);
|
|
1056
1345
|
}
|
|
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
1346
|
});
|
|
1066
1347
|
|
|
1067
1348
|
pi.on("session_shutdown", async () => {
|
|
1068
1349
|
lifecycleGeneration += 1;
|
|
1350
|
+
// Invalidate all long-lived command/tool/widget closures before awaiting
|
|
1351
|
+
// engine quiescence; the old session must not accept another run.
|
|
1352
|
+
active = false;
|
|
1353
|
+
clearWidget();
|
|
1069
1354
|
if (widgetTimer) {
|
|
1070
1355
|
clearInterval(widgetTimer);
|
|
1071
1356
|
widgetTimer = undefined;
|
|
@@ -1076,13 +1361,12 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1076
1361
|
}
|
|
1077
1362
|
protocolAbortController?.abort();
|
|
1078
1363
|
protocolAbortController = undefined;
|
|
1079
|
-
unsubscribeTreeSignal?.();
|
|
1080
|
-
unsubscribeTreeSignal = undefined;
|
|
1081
1364
|
closingEngine?.dispose();
|
|
1082
1365
|
engine = undefined;
|
|
1083
|
-
active = false;
|
|
1084
1366
|
branchQuiesce = undefined;
|
|
1085
|
-
protocolCheck =
|
|
1367
|
+
protocolCheck = undefined;
|
|
1368
|
+
protocolProbe = undefined;
|
|
1369
|
+
protocolInFlight = undefined;
|
|
1086
1370
|
protocolError = undefined;
|
|
1087
1371
|
deliverResult = undefined;
|
|
1088
1372
|
});
|