@signalridge/pi-workflows 1.7.5 → 1.9.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 +26 -0
- package/README.md +157 -10
- package/package.json +3 -2
- package/skills/workflow-authoring/SKILL.md +1 -1
- package/skills/workflow-authoring/references/capabilities.md +1 -1
- package/skills/workflow-authoring/references/capability-details.md +1 -1
- package/skills/workflow-patterns/SKILL.md +1 -1
- package/skills/workflow-review/SKILL.md +1 -1
- package/src/arming.ts +264 -3
- package/src/engine.ts +1212 -379
- package/src/errors.ts +26 -0
- package/src/index.ts +578 -225
- package/src/inspection.ts +544 -0
- package/src/journal.ts +38 -11
- package/src/orchestrator.ts +22 -4
- package/src/runtime.ts +427 -168
- package/src/safe-text.ts +4 -35
- package/src/ui-bus.ts +243 -0
- package/src/ui-command-center.ts +1032 -0
- package/src/ui-inspection.ts +206 -0
- package/src/ui-presentation.ts +196 -0
- package/src/ui.ts +43 -241
- package/src/vm-bootstrap.ts +218 -0
- package/src/vm-helper.ts +502 -0
package/src/index.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
import { sanitizeDisplayText } from "@signalridge/pi-ui";
|
|
5
7
|
import { Type } from "@sinclair/typebox";
|
|
6
|
-
import {
|
|
7
|
-
DEFAULT_TRIGGER_WORD,
|
|
8
|
-
deduplicateWorkflowDirective,
|
|
9
|
-
hasTriggerWord,
|
|
10
|
-
WORKFLOW_ARMED_DIRECTIVE,
|
|
11
|
-
} from "./arming.js";
|
|
8
|
+
import { DEFAULT_TRIGGER_WORD, hasTriggerWord, WORKFLOW_ARMING_MESSAGE_TYPE, WorkflowTurnArming } from "./arming.js";
|
|
12
9
|
import { BUILTIN_WORKFLOWS, validateBuiltinArgs } from "./builtins.js";
|
|
13
10
|
import { type CommandResult, resolveCodeReviewScope } from "./code-review-scope.js";
|
|
14
11
|
import { type ScriptStartOptions, type ScriptStartResult, WorkflowEngine, WorkflowWaitAbortedError } from "./engine.js";
|
|
@@ -28,6 +25,7 @@ import {
|
|
|
28
25
|
type WorkflowStrengthTable,
|
|
29
26
|
} from "./strengths.js";
|
|
30
27
|
import { liveWidgetLines, showWorkflowNavigator } from "./ui.js";
|
|
28
|
+
import { buildWorkflowUiSnapshot, openOwnedConversation, WorkflowUiBridge, type WorkflowUiSurface } from "./ui-bus.js";
|
|
31
29
|
import {
|
|
32
30
|
loadProjectWorkflowSettings,
|
|
33
31
|
loadWorkflowSettings,
|
|
@@ -114,6 +112,15 @@ function textResult(text: string, details: unknown = {}) {
|
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
114
|
|
|
115
|
+
/** Bounded display copies; command notifications never render stored control bytes. */
|
|
116
|
+
function notifyWorkflow(
|
|
117
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
118
|
+
message: string,
|
|
119
|
+
type: "info" | "warning" | "error" = "info",
|
|
120
|
+
): void {
|
|
121
|
+
ctx.ui.notify(sanitizeDisplayText(message.slice(0, 8_000), { multiline: true }).slice(0, 4_000), type);
|
|
122
|
+
}
|
|
123
|
+
|
|
117
124
|
/**
|
|
118
125
|
* Run one command for diff discovery. argv array, never a shell string — a
|
|
119
126
|
* branch name or path reaches this from user input.
|
|
@@ -335,6 +342,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
335
342
|
let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
336
343
|
let lifecycleGeneration = 0;
|
|
337
344
|
let deliveryGeneration = 0;
|
|
345
|
+
const deliveryObservers = new Map<string, string>();
|
|
338
346
|
let handledTreeEvent: unknown;
|
|
339
347
|
let journalBranchAnchor: string | null | undefined;
|
|
340
348
|
let protocolAbortController: AbortController | undefined;
|
|
@@ -343,9 +351,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
343
351
|
let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
|
|
344
352
|
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
345
353
|
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
346
|
-
/**
|
|
347
|
-
let
|
|
348
|
-
|
|
354
|
+
/** Public sends are void: acceptance is checked against the owning branch. */
|
|
355
|
+
let deliveryContext: ExtensionContext | undefined;
|
|
356
|
+
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
357
|
+
let inputPreparing = false;
|
|
358
|
+
const turnArming = new WorkflowTurnArming();
|
|
359
|
+
let compacting = false;
|
|
360
|
+
let navigating = false;
|
|
361
|
+
let deliveryBranchAnchor: string | null | undefined;
|
|
362
|
+
const DELIVERY_WAKE_PREFIX = "[pi-workflows:wake:";
|
|
363
|
+
const DELIVERY_WAKE_TEXT = "Workflow results are ready in the preceding context.";
|
|
364
|
+
const MAX_WAKE_ADMISSIONS = 256;
|
|
365
|
+
// Private, one-use admissions survive an earlier awaited input handler, not
|
|
366
|
+
// a lifecycle retirement. Missing/evicted IDs fail closed without tombstones.
|
|
367
|
+
const wakeAdmissions = new Map<
|
|
368
|
+
string,
|
|
369
|
+
{ generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
|
|
370
|
+
>();
|
|
371
|
+
/** Pending deliveries for completed runs, including durable recovery (A10). */
|
|
349
372
|
const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
|
|
350
373
|
const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
|
|
351
374
|
const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
|
|
@@ -353,6 +376,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
353
376
|
const PENDING_DELIVERY_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
354
377
|
let widgetTimer: ReturnType<typeof setInterval> | undefined;
|
|
355
378
|
let widgetUi: ExtensionContext["ui"] | undefined;
|
|
379
|
+
let workflowUiBridge: WorkflowUiBridge | undefined;
|
|
380
|
+
let workflowUiSurfaceId = randomUUID();
|
|
381
|
+
let refreshQueued = false;
|
|
356
382
|
|
|
357
383
|
const currentEngine = (): WorkflowEngine => {
|
|
358
384
|
if (!active || !engine || engine.isDisposed())
|
|
@@ -378,25 +404,52 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
378
404
|
if (!busy) return undefined;
|
|
379
405
|
const notice =
|
|
380
406
|
"Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
|
|
381
|
-
if (ctx.hasUI) ctx
|
|
407
|
+
if (ctx.hasUI) notifyWorkflow(ctx, notice, "warning");
|
|
382
408
|
else console.warn(`[pi-workflows] ${notice}`);
|
|
383
409
|
return { cancel: true };
|
|
384
410
|
};
|
|
411
|
+
const invalidateDelivery = (): void => {
|
|
412
|
+
workflowUiBridge?.retire(workflowUiSurfaceId);
|
|
413
|
+
workflowUiSurfaceId = randomUUID();
|
|
414
|
+
deliveryGeneration++;
|
|
415
|
+
deliveryObservers.clear();
|
|
416
|
+
turnArming.retire();
|
|
417
|
+
wakeAdmissions.clear();
|
|
418
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
419
|
+
deliveryTimer = undefined;
|
|
420
|
+
};
|
|
385
421
|
const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
386
422
|
if (shutdownQuiesce) return shutdownQuiesce;
|
|
423
|
+
// Retire admission and delivery BEFORE aborting children, including when
|
|
424
|
+
// subagents initiates shutdown first. Journal cleanup retains its separate
|
|
425
|
+
// lifecycle generation until the native shutdown hook finishes quiescence.
|
|
426
|
+
active = false;
|
|
427
|
+
invalidateDelivery();
|
|
428
|
+
deliveryContext = undefined;
|
|
387
429
|
const closingEngine = engine;
|
|
388
|
-
if (!closingEngine || closingEngine.isDisposed())
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
430
|
+
if (!closingEngine || closingEngine.isDisposed()) {
|
|
431
|
+
shutdownQuiesce = Promise.resolve({ settled: true, pending: [] });
|
|
432
|
+
return shutdownQuiesce;
|
|
433
|
+
}
|
|
434
|
+
// Publish the handshake promise before abort can synchronously re-enter
|
|
435
|
+
// the bus through an owned-child cleanup callback.
|
|
436
|
+
let settle: (result: { settled: boolean; pending: string[]; diagnostic?: string }) => void = () => {};
|
|
437
|
+
shutdownQuiesce = new Promise((resolve) => {
|
|
438
|
+
settle = resolve;
|
|
439
|
+
});
|
|
440
|
+
void closingEngine.quiesceForBranchChange().then(settle, (error: unknown) => {
|
|
441
|
+
settle({
|
|
442
|
+
settled: false,
|
|
443
|
+
pending: [],
|
|
444
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
445
|
+
});
|
|
446
|
+
});
|
|
394
447
|
return shutdownQuiesce;
|
|
395
448
|
};
|
|
396
449
|
// Subagents starts this handshake before retiring the owned-agent RPC. When
|
|
397
450
|
// workflows shuts down first, its native hook performs the same operation.
|
|
398
451
|
pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
|
|
399
|
-
if (!
|
|
452
|
+
if (!engine) return;
|
|
400
453
|
const respond = (raw as { respond?: unknown } | null)?.respond;
|
|
401
454
|
if (typeof respond === "function") respond(beginShutdownQuiesce());
|
|
402
455
|
});
|
|
@@ -405,6 +458,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
405
458
|
const event = (raw as { event?: unknown } | null)?.event;
|
|
406
459
|
if (!event || event === handledTreeEvent) return;
|
|
407
460
|
handledTreeEvent = event;
|
|
461
|
+
invalidateDelivery();
|
|
462
|
+
pendingDeliveryMarkers.clear();
|
|
408
463
|
engine.fenceCommittedBranch();
|
|
409
464
|
});
|
|
410
465
|
|
|
@@ -427,16 +482,55 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
427
482
|
.list()
|
|
428
483
|
.map((summary) => workflowEngine.getRun(String(summary.runId)))
|
|
429
484
|
.filter((run): run is NonNullable<typeof run> => run !== undefined);
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
485
|
+
const hasActive = runs.some((run) => !["completed", "failed", "stopped"].includes(run.status));
|
|
486
|
+
const surfaceId = workflowUiSurfaceId;
|
|
487
|
+
tui.setWidget(
|
|
488
|
+
"pi-workflows",
|
|
489
|
+
hasActive
|
|
490
|
+
? (host, theme) => ({
|
|
491
|
+
render(width) {
|
|
492
|
+
if (
|
|
493
|
+
!active ||
|
|
494
|
+
engine !== workflowEngine ||
|
|
495
|
+
workflowEngine.isDisposed() ||
|
|
496
|
+
workflowUiSurfaceId !== surfaceId ||
|
|
497
|
+
!Number.isFinite(width) ||
|
|
498
|
+
width < 1
|
|
499
|
+
)
|
|
500
|
+
return [];
|
|
501
|
+
const height = Number.isFinite(host.terminal.rows) ? Math.max(0, host.terminal.rows) : 24;
|
|
502
|
+
const rowBudget = Math.max(1, Math.min(12, Math.floor(height / 4)));
|
|
503
|
+
return liveWidgetLines(
|
|
504
|
+
runs,
|
|
505
|
+
workflowSettings.progressMode,
|
|
506
|
+
workflowSettings.maxAgentsShown,
|
|
507
|
+
rowBudget,
|
|
508
|
+
).map((line, index) =>
|
|
509
|
+
truncateToWidth(theme.fg(index === 0 ? "accent" : "muted", line), Math.floor(width)),
|
|
510
|
+
);
|
|
511
|
+
},
|
|
512
|
+
invalidate() {},
|
|
513
|
+
})
|
|
514
|
+
: undefined,
|
|
515
|
+
);
|
|
516
|
+
workflowUiBridge?.publish();
|
|
517
|
+
if (hasActive && !widgetTimer) {
|
|
433
518
|
widgetTimer = setInterval(refreshWidget, 2_000);
|
|
434
|
-
} else if (
|
|
519
|
+
} else if (!hasActive && widgetTimer) {
|
|
435
520
|
clearInterval(widgetTimer);
|
|
436
521
|
widgetTimer = undefined;
|
|
437
522
|
}
|
|
438
523
|
};
|
|
439
|
-
const notifyRunChanged = (): void =>
|
|
524
|
+
const notifyRunChanged = (): void => {
|
|
525
|
+
if (refreshQueued) return;
|
|
526
|
+
refreshQueued = true;
|
|
527
|
+
const surfaceId = workflowUiSurfaceId;
|
|
528
|
+
queueMicrotask(() => {
|
|
529
|
+
refreshQueued = false;
|
|
530
|
+
if (surfaceId !== workflowUiSurfaceId) return;
|
|
531
|
+
refreshWidget();
|
|
532
|
+
});
|
|
533
|
+
};
|
|
440
534
|
|
|
441
535
|
const DEFAULT_EXCLUDED_TOOLS = ["workflow", "workflow_control"] as const;
|
|
442
536
|
|
|
@@ -484,17 +578,98 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
484
578
|
throw new Error("Provide either `script` or `name`.");
|
|
485
579
|
};
|
|
486
580
|
|
|
581
|
+
const showNavigator = async (ctx: ExtensionContext, initialRunId?: string): Promise<void> => {
|
|
582
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
583
|
+
await showWorkflowNavigator(ctx, currentEngine, { initialRunId });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const selectedEngine = currentEngine();
|
|
587
|
+
const surfaceId = workflowUiSurfaceId;
|
|
588
|
+
const manager = ctx.sessionManager;
|
|
589
|
+
const sessionId = manager.getSessionId();
|
|
590
|
+
const resolve = (): WorkflowEngine => {
|
|
591
|
+
if (
|
|
592
|
+
surfaceId !== workflowUiSurfaceId ||
|
|
593
|
+
selectedEngine !== currentEngine() ||
|
|
594
|
+
manager !== deliveryContext?.sessionManager ||
|
|
595
|
+
manager.getSessionId() !== sessionId ||
|
|
596
|
+
!ownsDeliveryBranch()
|
|
597
|
+
)
|
|
598
|
+
throw new Error("Workflow inspector belongs to a retired session or branch.");
|
|
599
|
+
return selectedEngine;
|
|
600
|
+
};
|
|
601
|
+
await showWorkflowNavigator(ctx, resolve, {
|
|
602
|
+
initialRunId,
|
|
603
|
+
getPresentation: (runId) => resolve().getInspection(runId),
|
|
604
|
+
openConversation: async (runId, callIndex) => {
|
|
605
|
+
const current = resolve();
|
|
606
|
+
if (!/^(?:0|[1-9]\d*)$/u.test(callIndex)) throw new Error("This call has no retained root conversation.");
|
|
607
|
+
const state = current.getState(runId);
|
|
608
|
+
const run = state?.run;
|
|
609
|
+
const agentId = run?.agentIds[callIndex];
|
|
610
|
+
const attempt = run?.attempts[callIndex];
|
|
611
|
+
if (!state || !run || !agentId || !Number.isInteger(attempt) || (attempt ?? 0) < 1)
|
|
612
|
+
throw new Error("No retained conversation is available for this call.");
|
|
613
|
+
const executionGeneration = state.executionGeneration;
|
|
614
|
+
await openOwnedConversation(
|
|
615
|
+
pi.events,
|
|
616
|
+
agentId,
|
|
617
|
+
{ extension: "pi-workflows", runId, nodeId: `call-${callIndex}`, attemptId: `attempt-${attempt}` },
|
|
618
|
+
() => {
|
|
619
|
+
try {
|
|
620
|
+
return (
|
|
621
|
+
resolve().getState(runId) === state &&
|
|
622
|
+
state.executionGeneration === executionGeneration &&
|
|
623
|
+
run.agentIds[callIndex] === agentId &&
|
|
624
|
+
run.attempts[callIndex] === attempt
|
|
625
|
+
);
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const workflowUiSurface = (): WorkflowUiSurface | undefined => {
|
|
636
|
+
const ctx = deliveryContext;
|
|
637
|
+
if (!active || !engine || engine.isDisposed() || ctx?.mode !== "tui" || !ctx.hasUI || !ownsDeliveryBranch())
|
|
638
|
+
return undefined;
|
|
639
|
+
return {
|
|
640
|
+
snapshot: buildWorkflowUiSnapshot(engine, workflowUiSurfaceId),
|
|
641
|
+
open: (runId) => showNavigator(ctx, runId),
|
|
642
|
+
notifyError: (message) => notifyWorkflow(ctx, message, "error"),
|
|
643
|
+
};
|
|
644
|
+
};
|
|
645
|
+
|
|
487
646
|
const registerSurface = (): void => {
|
|
488
647
|
if (active) return;
|
|
489
648
|
active = true;
|
|
490
649
|
const workflowEngine = currentEngine();
|
|
491
650
|
workflowEngine.onRunSettled = notifyRunChanged;
|
|
651
|
+
workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
|
|
492
652
|
workflowEngine.onRuntimeEvent = notifyRunChanged;
|
|
653
|
+
workflowEngine.onInspectionChanged = notifyRunChanged;
|
|
654
|
+
if (typeof pi.registerMessageRenderer === "function") {
|
|
655
|
+
pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
|
|
656
|
+
const original =
|
|
657
|
+
typeof message.content === "string"
|
|
658
|
+
? message.content
|
|
659
|
+
: message.content
|
|
660
|
+
.filter((part) => part.type === "text")
|
|
661
|
+
.map((part) => part.text)
|
|
662
|
+
.join("\n");
|
|
663
|
+
const display = sanitizeDisplayText(original, { multiline: true });
|
|
664
|
+
return new Text(theme.fg("toolOutput", display.slice(0, expanded ? 8000 : 2000)), 0, 0);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
493
667
|
// `control("resume")` and the provider-limit retry start executions with no
|
|
494
668
|
// caller to pass options for them, and the strength table is deliberately
|
|
495
669
|
// never frozen onto a run. Without this they would resume with no table and
|
|
496
670
|
// re-run every finished call untabled.
|
|
497
671
|
workflowEngine.strengths = () => workflowSettings.strengths;
|
|
672
|
+
workflowEngine.loadSavedWorkflow = resolveNestedWorkflow;
|
|
498
673
|
// Command/tool registrations persist across session replacement, but the
|
|
499
674
|
// widget belongs to the newly restored engine and must be refreshed too.
|
|
500
675
|
refreshWidget();
|
|
@@ -519,11 +694,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
519
694
|
],
|
|
520
695
|
parameters: WorkflowToolSchema,
|
|
521
696
|
renderCall(args, theme) {
|
|
522
|
-
const label = args.name ?? (args.script !== undefined ? "script" : "");
|
|
697
|
+
const label = sanitizeDisplayText(args.name ?? (args.script !== undefined ? "script" : ""));
|
|
523
698
|
return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
|
|
524
699
|
},
|
|
525
700
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
526
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
|
|
701
|
+
const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
|
|
702
|
+
multiline: true,
|
|
703
|
+
});
|
|
527
704
|
if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
|
|
528
705
|
const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
|
|
529
706
|
return new Text(
|
|
@@ -585,7 +762,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
585
762
|
replacement,
|
|
586
763
|
);
|
|
587
764
|
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
588
|
-
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
589
765
|
return textResult(formatStart(resumed), resumed);
|
|
590
766
|
}
|
|
591
767
|
const result = await workflowEngine.start(script, options);
|
|
@@ -610,10 +786,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
610
786
|
"List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
|
|
611
787
|
parameters: ControlSchema,
|
|
612
788
|
renderCall(args, theme) {
|
|
613
|
-
return new Text(
|
|
789
|
+
return new Text(
|
|
790
|
+
theme.fg("toolTitle", theme.bold(`Workflow control: ${sanitizeDisplayText(args.action ?? "")}`)),
|
|
791
|
+
0,
|
|
792
|
+
0,
|
|
793
|
+
);
|
|
614
794
|
},
|
|
615
795
|
renderResult(result, { expanded }, theme) {
|
|
616
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
|
|
796
|
+
const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
|
|
797
|
+
multiline: true,
|
|
798
|
+
});
|
|
617
799
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
618
800
|
},
|
|
619
801
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
@@ -651,21 +833,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
651
833
|
try {
|
|
652
834
|
return currentEngine();
|
|
653
835
|
} catch (error: unknown) {
|
|
654
|
-
ctx
|
|
836
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
655
837
|
return undefined;
|
|
656
838
|
}
|
|
657
839
|
};
|
|
658
840
|
if (!subcommand) {
|
|
659
|
-
await
|
|
841
|
+
await showNavigator(ctx);
|
|
660
842
|
return;
|
|
661
843
|
}
|
|
662
844
|
if (subcommand === "run") {
|
|
663
845
|
if (!restArgs) {
|
|
664
|
-
ctx
|
|
846
|
+
notifyWorkflow(ctx, "/workflows run <prompt-or-script>", "warning");
|
|
665
847
|
return;
|
|
666
848
|
}
|
|
667
849
|
const runId = await runWorkflowFromPrompt(ctx, restArgs);
|
|
668
|
-
if (runId) ctx
|
|
850
|
+
if (runId) notifyWorkflow(ctx, `Workflow started in background: ${runId}`, "info");
|
|
669
851
|
return;
|
|
670
852
|
}
|
|
671
853
|
if (subcommand === "status") {
|
|
@@ -674,14 +856,15 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
674
856
|
if (restArgs) {
|
|
675
857
|
const run = workflowEngine.getRun(restArgs);
|
|
676
858
|
if (!run) {
|
|
677
|
-
ctx
|
|
859
|
+
notifyWorkflow(ctx, `Workflow run not found: ${restArgs}`, "warning");
|
|
678
860
|
return;
|
|
679
861
|
}
|
|
680
|
-
ctx
|
|
862
|
+
notifyWorkflow(ctx, `${run.runId} · ${run.status} · ${String(run.meta?.name ?? run.runId)}`, "info");
|
|
681
863
|
return;
|
|
682
864
|
}
|
|
683
865
|
const runs = workflowEngine.list();
|
|
684
|
-
|
|
866
|
+
notifyWorkflow(
|
|
867
|
+
ctx,
|
|
685
868
|
runs.length === 0
|
|
686
869
|
? "No workflow runs."
|
|
687
870
|
: runs.map((r) => `${r.runId} · ${r.status} · ${String(r.name)}`).join("\n"),
|
|
@@ -694,11 +877,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
694
877
|
if (!workflowEngine) return;
|
|
695
878
|
const runId = restArgs || String(workflowEngine.list()[0]?.runId ?? "");
|
|
696
879
|
if (!runId) {
|
|
697
|
-
ctx
|
|
880
|
+
notifyWorkflow(ctx, "No workflow runs to watch.", "warning");
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const run = workflowEngine.getRun(runId);
|
|
884
|
+
if (!run) {
|
|
885
|
+
notifyWorkflow(ctx, `Workflow run not found: ${runId}`, "warning");
|
|
698
886
|
return;
|
|
699
887
|
}
|
|
700
|
-
|
|
701
|
-
|
|
888
|
+
if (ctx.mode === "tui" && ctx.hasUI) await showNavigator(ctx, runId);
|
|
889
|
+
else notifyWorkflow(ctx, `${run.runId} · ${run.status} — use workflow_control outside TUI.`, "info");
|
|
702
890
|
return;
|
|
703
891
|
}
|
|
704
892
|
if (subcommand === "stop" || subcommand === "pause" || subcommand === "resume" || subcommand === "rm") {
|
|
@@ -712,7 +900,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
712
900
|
);
|
|
713
901
|
if (!runId) {
|
|
714
902
|
const destructive = subcommand === "stop" || subcommand === "rm";
|
|
715
|
-
|
|
903
|
+
notifyWorkflow(
|
|
904
|
+
ctx,
|
|
716
905
|
destructive
|
|
717
906
|
? `/workflows ${subcommand} <runId> — a run id is required; /workflows status lists them`
|
|
718
907
|
: `/workflows ${subcommand} <runId>`,
|
|
@@ -725,16 +914,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
725
914
|
if (!workflowEngine) return;
|
|
726
915
|
await workflowEngine.control(subcommand, runId);
|
|
727
916
|
if (subcommand === "resume") void deliverBackgroundResult(workflowEngine, runId);
|
|
728
|
-
ctx
|
|
917
|
+
notifyWorkflow(ctx, `Workflow ${subcommand} requested: ${runId}`, "info");
|
|
729
918
|
} catch (error: unknown) {
|
|
730
|
-
ctx
|
|
919
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
731
920
|
}
|
|
732
921
|
return;
|
|
733
922
|
}
|
|
734
923
|
if (subcommand === "save") {
|
|
735
924
|
const name = rest[0] ?? "";
|
|
736
925
|
if (!name) {
|
|
737
|
-
ctx
|
|
926
|
+
notifyWorkflow(ctx, "/workflows save <name> [runId]", "warning");
|
|
738
927
|
return;
|
|
739
928
|
}
|
|
740
929
|
const workflowEngine = engineForCommand();
|
|
@@ -742,13 +931,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
742
931
|
// Fallback to most recent run when runId omitted — matches upstream runs.find(r=>r.script)
|
|
743
932
|
const runId = rest[1] ?? String(workflowEngine.list()[0]?.runId ?? "");
|
|
744
933
|
if (!runId) {
|
|
745
|
-
ctx
|
|
934
|
+
notifyWorkflow(ctx, "No workflow runs to save.", "warning");
|
|
746
935
|
return;
|
|
747
936
|
}
|
|
748
937
|
try {
|
|
749
938
|
await saveWorkflowFromRun(ctx, name, runId);
|
|
750
939
|
} catch (error: unknown) {
|
|
751
|
-
ctx
|
|
940
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
752
941
|
}
|
|
753
942
|
return;
|
|
754
943
|
}
|
|
@@ -820,15 +1009,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
820
1009
|
};
|
|
821
1010
|
const [strength, target] = rest;
|
|
822
1011
|
if (strength === undefined) {
|
|
823
|
-
ctx
|
|
1012
|
+
notifyWorkflow(ctx, describe(), "info");
|
|
824
1013
|
return;
|
|
825
1014
|
}
|
|
826
1015
|
if (!isWorkflowStrength(strength)) {
|
|
827
|
-
|
|
1016
|
+
notifyWorkflow(
|
|
1017
|
+
ctx,
|
|
1018
|
+
`"${strength}" is not a workflow strength. Use one of ${WORKFLOW_STRENGTH_LIST}.`,
|
|
1019
|
+
"warning",
|
|
1020
|
+
);
|
|
828
1021
|
return;
|
|
829
1022
|
}
|
|
830
1023
|
if (target === undefined || rest.length !== 2) {
|
|
831
|
-
ctx
|
|
1024
|
+
notifyWorkflow(ctx, `/workflows strength [<${WORKFLOW_STRENGTHS.join("|")}> <tier>|off]`, "warning");
|
|
832
1025
|
return;
|
|
833
1026
|
}
|
|
834
1027
|
// `off` reads as "unset" unless the host really defines a tier by
|
|
@@ -837,7 +1030,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
837
1030
|
// to clear one.
|
|
838
1031
|
const unset = target === "off" && catalogue?.includes("off") !== true;
|
|
839
1032
|
if (!unset && catalogue && !catalogue.includes(target)) {
|
|
840
|
-
|
|
1033
|
+
notifyWorkflow(
|
|
1034
|
+
ctx,
|
|
841
1035
|
`"${target}" is not a tier this host defines. Available: ${catalogue.join(", ") || "(none)"}. ` +
|
|
842
1036
|
"Define it in /agents → Model tiers first.",
|
|
843
1037
|
"warning",
|
|
@@ -850,7 +1044,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
850
1044
|
// table, and this key replaces rather than merges — so the other two
|
|
851
1045
|
// strengths would go from routed to unmapped as a side effect of
|
|
852
1046
|
// setting this one. Refusing costs a retry; writing costs a run.
|
|
853
|
-
|
|
1047
|
+
notifyWorkflow(
|
|
1048
|
+
ctx,
|
|
854
1049
|
"The tier catalogue is unavailable, so the current table cannot be read. " +
|
|
855
1050
|
"Retry once the managed peer is reachable.",
|
|
856
1051
|
"warning",
|
|
@@ -870,7 +1065,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
870
1065
|
try {
|
|
871
1066
|
persistWorkflowSetting({ strengths: unset ? others : { ...others, [strength]: target } });
|
|
872
1067
|
} catch (error: unknown) {
|
|
873
|
-
|
|
1068
|
+
notifyWorkflow(
|
|
1069
|
+
ctx,
|
|
874
1070
|
`Could not save workflow strength routing: ${error instanceof Error ? error.message : String(error)}`,
|
|
875
1071
|
"warning",
|
|
876
1072
|
);
|
|
@@ -897,10 +1093,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
897
1093
|
"than as the clear keyword. To leave a strength unmapped here, remove its entry from the " +
|
|
898
1094
|
"`strengths` object in this project's settings.json."
|
|
899
1095
|
: "";
|
|
900
|
-
ctx
|
|
1096
|
+
notifyWorkflow(ctx, `${describe()}${shadowed}`, "info");
|
|
901
1097
|
return;
|
|
902
1098
|
}
|
|
903
|
-
ctx
|
|
1099
|
+
notifyWorkflow(ctx, `Unknown /workflows subcommand: ${subcommand}`, "warning");
|
|
904
1100
|
},
|
|
905
1101
|
});
|
|
906
1102
|
|
|
@@ -911,7 +1107,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
911
1107
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
912
1108
|
const action = tokens[0] ?? "status";
|
|
913
1109
|
if (action === "status") {
|
|
914
|
-
|
|
1110
|
+
notifyWorkflow(
|
|
1111
|
+
ctx,
|
|
915
1112
|
`Workflow progress: ${workflowSettings.progressMode}, max agents shown: ${workflowSettings.maxAgentsShown}`,
|
|
916
1113
|
"info",
|
|
917
1114
|
);
|
|
@@ -923,23 +1120,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
923
1120
|
} else if (action === "max") {
|
|
924
1121
|
const max = Number(tokens[1]);
|
|
925
1122
|
if (!Number.isInteger(max) || max < 1 || max > 32) {
|
|
926
|
-
ctx
|
|
1123
|
+
notifyWorkflow(ctx, "Usage: /workflows-progress max <1-32>", "warning");
|
|
927
1124
|
return;
|
|
928
1125
|
}
|
|
929
1126
|
patch = { maxAgentsShown: max };
|
|
930
1127
|
} else {
|
|
931
|
-
ctx
|
|
1128
|
+
notifyWorkflow(ctx, "Usage: /workflows-progress compact|detailed|status|max <N>", "warning");
|
|
932
1129
|
return;
|
|
933
1130
|
}
|
|
934
1131
|
try {
|
|
935
1132
|
persistWorkflowSetting(patch);
|
|
936
1133
|
refreshWidget();
|
|
937
|
-
|
|
1134
|
+
notifyWorkflow(
|
|
1135
|
+
ctx,
|
|
938
1136
|
`Workflow progress set to ${workflowSettings.progressMode}, max ${workflowSettings.maxAgentsShown}.`,
|
|
939
1137
|
"info",
|
|
940
1138
|
);
|
|
941
1139
|
} catch (error: unknown) {
|
|
942
|
-
|
|
1140
|
+
notifyWorkflow(
|
|
1141
|
+
ctx,
|
|
943
1142
|
`Could not save workflow progress settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
944
1143
|
"warning",
|
|
945
1144
|
);
|
|
@@ -950,7 +1149,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
950
1149
|
// /workflows-trigger: keyword arming (A5). Typing the bounded word
|
|
951
1150
|
// `workflow`/`workflows` — or a configured synonym — in an ordinary message
|
|
952
1151
|
// counts as an explicit opt-in to multi-agent orchestration, and the message
|
|
953
|
-
//
|
|
1152
|
+
// receives hidden model guidance. Arming AUTHORIZES the tool; it never forces a run,
|
|
954
1153
|
// and it never opens UI. A user asking a question about workflows still gets
|
|
955
1154
|
// an ordinary answer.
|
|
956
1155
|
const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
|
|
@@ -968,24 +1167,25 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
968
1167
|
if (token === "off") {
|
|
969
1168
|
keywordTriggerEnabled = false;
|
|
970
1169
|
persistTriggerSettings();
|
|
971
|
-
ctx
|
|
1170
|
+
notifyWorkflow(ctx, "Workflow keyword trigger disabled.", "info");
|
|
972
1171
|
return;
|
|
973
1172
|
}
|
|
974
1173
|
if (token === "on") {
|
|
975
1174
|
keywordTriggerEnabled = true;
|
|
976
1175
|
persistTriggerSettings();
|
|
977
|
-
ctx
|
|
1176
|
+
notifyWorkflow(ctx, `Workflow keyword trigger armed on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}".`, "info");
|
|
978
1177
|
return;
|
|
979
1178
|
}
|
|
980
1179
|
if (token === "reset") {
|
|
981
1180
|
triggerKeyword = undefined;
|
|
982
1181
|
keywordTriggerEnabled = true;
|
|
983
1182
|
persistTriggerSettings();
|
|
984
|
-
ctx
|
|
1183
|
+
notifyWorkflow(ctx, `Workflow keyword trigger reset to default "${DEFAULT_TRIGGER_WORD}".`, "info");
|
|
985
1184
|
return;
|
|
986
1185
|
}
|
|
987
1186
|
if (token === "status") {
|
|
988
|
-
|
|
1187
|
+
notifyWorkflow(
|
|
1188
|
+
ctx,
|
|
989
1189
|
`Workflow keyword trigger: ${keywordTriggerEnabled ? "armed" : "disabled"} on "${triggerKeyword ?? DEFAULT_TRIGGER_WORD}"`,
|
|
990
1190
|
"info",
|
|
991
1191
|
);
|
|
@@ -993,87 +1193,80 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
993
1193
|
}
|
|
994
1194
|
const keyword = token === "set" ? (args.trim().split(/\s+/)[1] ?? "") : token;
|
|
995
1195
|
if (!TRIGGER_WORD_PATTERN.test(keyword)) {
|
|
996
|
-
|
|
1196
|
+
notifyWorkflow(
|
|
1197
|
+
ctx,
|
|
1198
|
+
"Usage: /workflows-trigger set <one-word-keyword> | off | on | reset | status",
|
|
1199
|
+
"warning",
|
|
1200
|
+
);
|
|
997
1201
|
return;
|
|
998
1202
|
}
|
|
999
1203
|
triggerKeyword = keyword.toLowerCase();
|
|
1000
1204
|
keywordTriggerEnabled = true;
|
|
1001
1205
|
persistTriggerSettings();
|
|
1002
|
-
ctx
|
|
1206
|
+
notifyWorkflow(ctx, `Workflow keyword trigger armed on "${keyword}".`, "info");
|
|
1003
1207
|
},
|
|
1004
1208
|
});
|
|
1005
1209
|
|
|
1006
|
-
// Keyword arming input hook (A5).
|
|
1007
|
-
//
|
|
1210
|
+
// Keyword arming input hook (A5). Record provenance, never append model
|
|
1211
|
+
// guidance to native user text: the model may use `workflow` and may still
|
|
1008
1212
|
// decline, which is what keeps "how do workflows work?" an ordinary
|
|
1009
1213
|
// question. Nothing is swallowed and no UI opens.
|
|
1010
|
-
pi.on("input", (event) => {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1214
|
+
pi.on("input", (event, ctx) => {
|
|
1215
|
+
const ownWake = event.source === "extension" && event.text.startsWith(DELIVERY_WAKE_PREFIX);
|
|
1216
|
+
if (ownWake) {
|
|
1217
|
+
const admission = wakeAdmissions.get(event.text);
|
|
1218
|
+
wakeAdmissions.delete(event.text); // exact, one-use ownership, even on rejection
|
|
1219
|
+
if (
|
|
1220
|
+
!admission ||
|
|
1221
|
+
admission.generation !== deliveryGeneration ||
|
|
1222
|
+
admission.context !== deliveryContext ||
|
|
1223
|
+
admission.engine !== engine ||
|
|
1224
|
+
!canDeliver()
|
|
1225
|
+
) {
|
|
1226
|
+
turnArming.retire();
|
|
1227
|
+
return { action: "handled" as const };
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
if (
|
|
1231
|
+
admission.anchor &&
|
|
1232
|
+
!admission.context.sessionManager.getBranch().some((entry) => entry.id === admission.anchor)
|
|
1233
|
+
) {
|
|
1234
|
+
turnArming.retire();
|
|
1235
|
+
return { action: "handled" as const };
|
|
1236
|
+
}
|
|
1237
|
+
} catch {
|
|
1238
|
+
turnArming.retire();
|
|
1239
|
+
return { action: "handled" as const };
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
inputPreparing = true;
|
|
1243
|
+
navigating = false; // a fresh prompt supersedes an uncommitted navigation
|
|
1244
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1245
|
+
deliveryTimer = undefined;
|
|
1246
|
+
const text = event.text;
|
|
1247
|
+
const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(text, triggerKeyword);
|
|
1248
|
+
const substantive = effortLevel !== "off" && text.trim().length >= 16 && !text.trim().startsWith("/");
|
|
1249
|
+
turnArming.observe(
|
|
1250
|
+
{ ...event, text },
|
|
1251
|
+
ctx,
|
|
1252
|
+
active && (explicitTrigger || substantive),
|
|
1253
|
+
effortLevel,
|
|
1254
|
+
explicitTrigger,
|
|
1255
|
+
);
|
|
1256
|
+
if (ownWake)
|
|
1014
1257
|
return {
|
|
1015
1258
|
action: "transform" as const,
|
|
1016
|
-
text:
|
|
1259
|
+
text: DELIVERY_WAKE_TEXT,
|
|
1017
1260
|
...(event.images ? { images: event.images } : {}),
|
|
1018
1261
|
};
|
|
1019
|
-
}
|
|
1020
|
-
const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(event.text, triggerKeyword);
|
|
1021
|
-
const substantive = effortLevel !== "off" && event.text.trim().length >= 16 && !event.text.trim().startsWith("/");
|
|
1022
|
-
if (!explicitTrigger && !substantive) return { action: "continue" as const };
|
|
1023
|
-
if (event.text.includes(WORKFLOW_ARMED_DIRECTIVE)) return { action: "continue" as const };
|
|
1024
|
-
const effortDirective =
|
|
1025
|
-
effortLevel === "ultra"
|
|
1026
|
-
? 'Effort: ULTRA. Be exhaustive: use broad parallel review, verification, and completeness checks. Width is where the exhaustiveness goes: keep steps at strength: "medium" and reserve "high" for a step whose error is expensive to reverse, if the run has one at all.'
|
|
1027
|
-
: effortLevel === "high"
|
|
1028
|
-
? "Effort: HIGH. Be thorough: use several independent reviewers and an adversarial verification pass."
|
|
1029
|
-
: "";
|
|
1030
|
-
return {
|
|
1031
|
-
action: "transform" as const,
|
|
1032
|
-
text: `${event.text}\n\n${WORKFLOW_ARMED_DIRECTIVE}${effortDirective ? `\n${effortDirective}` : ""}`,
|
|
1033
|
-
...(event.images ? { images: event.images } : {}),
|
|
1034
|
-
};
|
|
1262
|
+
return { action: "continue" as const };
|
|
1035
1263
|
});
|
|
1036
1264
|
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
pi.on("
|
|
1041
|
-
|
|
1042
|
-
let lastUserIndex = -1;
|
|
1043
|
-
let lastUserMessage: UserMessage | undefined;
|
|
1044
|
-
for (let index = 0; index < event.messages.length; index += 1) {
|
|
1045
|
-
const message = event.messages[index];
|
|
1046
|
-
if (message && "role" in message && message.role === "user") {
|
|
1047
|
-
lastUserIndex = index;
|
|
1048
|
-
lastUserMessage = message;
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
const message = lastUserMessage;
|
|
1052
|
-
if (lastUserIndex < 0 || !message || !("content" in message)) return;
|
|
1053
|
-
let seen = false;
|
|
1054
|
-
const deduplicate = (text: string): string =>
|
|
1055
|
-
text.replaceAll(WORKFLOW_ARMED_DIRECTIVE, (directive) => {
|
|
1056
|
-
if (seen) return "";
|
|
1057
|
-
seen = true;
|
|
1058
|
-
return directive;
|
|
1059
|
-
});
|
|
1060
|
-
const content =
|
|
1061
|
-
typeof message.content === "string"
|
|
1062
|
-
? deduplicate(message.content)
|
|
1063
|
-
: message.content.map((part) => {
|
|
1064
|
-
if (part.type !== "text") return part;
|
|
1065
|
-
const text = deduplicate(part.text);
|
|
1066
|
-
return text === part.text ? part : { ...part, text };
|
|
1067
|
-
});
|
|
1068
|
-
if (
|
|
1069
|
-
content === message.content ||
|
|
1070
|
-
(Array.isArray(content) && content.every((part, index) => part === message.content[index]))
|
|
1071
|
-
)
|
|
1072
|
-
return;
|
|
1073
|
-
const messages = event.messages.slice();
|
|
1074
|
-
messages[lastUserIndex] = { ...message, content };
|
|
1075
|
-
return { messages };
|
|
1076
|
-
});
|
|
1265
|
+
// Hidden guidance is turn-local, including on reload and after later hooks
|
|
1266
|
+
// navigate while before_agent_start is awaiting. Never edit native history.
|
|
1267
|
+
// Request-local overlays never enter native history or summarization.
|
|
1268
|
+
pi.on("message_start", (event, ctx) => turnArming.messageStarted(event, ctx));
|
|
1269
|
+
pi.on("context", (event, ctx) => turnArming.filterContext(event, ctx));
|
|
1077
1270
|
|
|
1078
1271
|
// Standing effort mode mirrors the reference: it auto-arms substantive
|
|
1079
1272
|
// interactive messages but remains guidance, not an inferred spend ceiling.
|
|
@@ -1082,7 +1275,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1082
1275
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
1083
1276
|
const level = args.trim().toLowerCase();
|
|
1084
1277
|
if (level !== "off" && level !== "high" && level !== "ultra") {
|
|
1085
|
-
ctx
|
|
1278
|
+
notifyWorkflow(ctx, `Effort is currently "${effortLevel}". Usage: /effort off|high|ultra`, "info");
|
|
1086
1279
|
return;
|
|
1087
1280
|
}
|
|
1088
1281
|
effortLevel = level;
|
|
@@ -1091,7 +1284,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1091
1284
|
} catch {
|
|
1092
1285
|
/* warning is non-fatal */
|
|
1093
1286
|
}
|
|
1094
|
-
|
|
1287
|
+
notifyWorkflow(
|
|
1288
|
+
ctx,
|
|
1095
1289
|
level === "off"
|
|
1096
1290
|
? "Effort off — messages are no longer auto-armed."
|
|
1097
1291
|
: `Effort ${level} enabled for substantive messages.`,
|
|
@@ -1108,7 +1302,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1108
1302
|
} catch {
|
|
1109
1303
|
/* warning is non-fatal */
|
|
1110
1304
|
}
|
|
1111
|
-
|
|
1305
|
+
notifyWorkflow(
|
|
1306
|
+
ctx,
|
|
1112
1307
|
effortLevel === "off"
|
|
1113
1308
|
? "Ultracode off."
|
|
1114
1309
|
: "Ultracode on — substantive messages use exhaustive workflow guidance.",
|
|
@@ -1136,7 +1331,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1136
1331
|
if (name === "code-review") {
|
|
1137
1332
|
const cwd = ctx.cwd;
|
|
1138
1333
|
const scope = resolveCodeReviewScope(text, (file, argv) => runCommand(file, argv, cwd));
|
|
1139
|
-
for (const notice of scope.notices) ctx
|
|
1334
|
+
for (const notice of scope.notices) notifyWorkflow(ctx, notice, "warning");
|
|
1140
1335
|
if (!scope.diff.trim()) return;
|
|
1141
1336
|
scriptArgs = { diff: scope.diff, diffSource: scope.diffSource };
|
|
1142
1337
|
} else {
|
|
@@ -1145,7 +1340,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1145
1340
|
try {
|
|
1146
1341
|
validateBuiltinArgs(name, scriptArgs);
|
|
1147
1342
|
} catch (error: unknown) {
|
|
1148
|
-
ctx
|
|
1343
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "warning");
|
|
1149
1344
|
return;
|
|
1150
1345
|
}
|
|
1151
1346
|
const options: ScriptStartOptions = {
|
|
@@ -1160,9 +1355,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1160
1355
|
const workflowEngine = currentEngine();
|
|
1161
1356
|
const result = await workflowEngine.start(descriptor.script, options);
|
|
1162
1357
|
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1163
|
-
ctx
|
|
1358
|
+
notifyWorkflow(ctx, `Workflow ${name} started in background: ${result.runId}`, "info");
|
|
1164
1359
|
} catch (error: unknown) {
|
|
1165
|
-
ctx
|
|
1360
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1166
1361
|
}
|
|
1167
1362
|
},
|
|
1168
1363
|
});
|
|
@@ -1171,8 +1366,64 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1171
1366
|
|
|
1172
1367
|
// Neither before-event is a commit point. Pi can veto in a later handler,
|
|
1173
1368
|
// or summarization may abort after every handler has run.
|
|
1174
|
-
|
|
1175
|
-
|
|
1369
|
+
const beforeNavigation = (ctx: ExtensionContext): { cancel: true } | undefined => {
|
|
1370
|
+
turnArming.retire();
|
|
1371
|
+
const veto = navigationPreflight(ctx);
|
|
1372
|
+
if (!veto) {
|
|
1373
|
+
navigating = true;
|
|
1374
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1375
|
+
deliveryTimer = undefined;
|
|
1376
|
+
}
|
|
1377
|
+
return veto;
|
|
1378
|
+
};
|
|
1379
|
+
pi.on("session_before_switch", (_event, ctx) => beforeNavigation(ctx));
|
|
1380
|
+
pi.on("session_before_tree", (event, ctx) => {
|
|
1381
|
+
// The public preparation array is mutable; remove only legacy owned
|
|
1382
|
+
// custom guidance, never quoted user content or stored history.
|
|
1383
|
+
const entries = event.preparation?.entriesToSummarize ?? [];
|
|
1384
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
1385
|
+
const entry = entries[index];
|
|
1386
|
+
if (entry?.type === "custom_message" && entry.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
|
|
1387
|
+
entries.splice(index, 1);
|
|
1388
|
+
}
|
|
1389
|
+
return beforeNavigation(ctx);
|
|
1390
|
+
});
|
|
1391
|
+
pi.on("session_before_fork", (_event, ctx) => beforeNavigation(ctx));
|
|
1392
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1393
|
+
inputPreparing = true;
|
|
1394
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1395
|
+
deliveryTimer = undefined;
|
|
1396
|
+
return turnArming.consume(event, ctx);
|
|
1397
|
+
});
|
|
1398
|
+
pi.on("agent_start", () => {
|
|
1399
|
+
inputPreparing = false;
|
|
1400
|
+
});
|
|
1401
|
+
pi.on("session_before_compact", (event) => {
|
|
1402
|
+
for (const messages of [
|
|
1403
|
+
event.preparation?.messagesToSummarize ?? [],
|
|
1404
|
+
event.preparation?.turnPrefixMessages ?? [],
|
|
1405
|
+
]) {
|
|
1406
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
1407
|
+
const message = messages[index];
|
|
1408
|
+
if (message?.role === "custom" && message.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
|
|
1409
|
+
messages.splice(index, 1);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
compacting = true;
|
|
1413
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1414
|
+
deliveryTimer = undefined;
|
|
1415
|
+
});
|
|
1416
|
+
const afterCompact = (): void => {
|
|
1417
|
+
compacting = false;
|
|
1418
|
+
schedulePendingDeliveries();
|
|
1419
|
+
};
|
|
1420
|
+
pi.on("session_compact", afterCompact);
|
|
1421
|
+
pi.on("session_compact_failed", afterCompact);
|
|
1422
|
+
pi.on("agent_settled", () => {
|
|
1423
|
+
turnArming.retire();
|
|
1424
|
+
inputPreparing = false;
|
|
1425
|
+
schedulePendingDeliveries();
|
|
1426
|
+
});
|
|
1176
1427
|
pi.on("session_tree", (event, ctx) => {
|
|
1177
1428
|
let workflowEngine: WorkflowEngine;
|
|
1178
1429
|
try {
|
|
@@ -1180,6 +1431,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1180
1431
|
} catch {
|
|
1181
1432
|
return;
|
|
1182
1433
|
}
|
|
1434
|
+
invalidateDelivery();
|
|
1435
|
+
pendingDeliveryMarkers.clear();
|
|
1436
|
+
inputPreparing = false;
|
|
1437
|
+
compacting = false;
|
|
1438
|
+
navigating = false;
|
|
1183
1439
|
if (handledTreeEvent !== event) {
|
|
1184
1440
|
// A controller abort can synchronously call stop-owned RPC. Detach the
|
|
1185
1441
|
// old subagent records first, regardless of extension load order, so its
|
|
@@ -1188,13 +1444,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1188
1444
|
workflowEngine.fenceCommittedBranch();
|
|
1189
1445
|
}
|
|
1190
1446
|
handledTreeEvent = event;
|
|
1191
|
-
deliveryGeneration++;
|
|
1192
1447
|
try {
|
|
1193
1448
|
const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
1194
1449
|
journalBranchAnchor =
|
|
1195
1450
|
ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1196
1451
|
workflowEngine.restore(entries);
|
|
1197
1452
|
workflowEngine.resumeLifecycle();
|
|
1453
|
+
deliveryContext = ctx;
|
|
1454
|
+
deliveryBranchAnchor = journalBranchAnchor;
|
|
1455
|
+
hydratePendingDeliveries(entries);
|
|
1456
|
+
schedulePendingDeliveries();
|
|
1198
1457
|
} catch (error: unknown) {
|
|
1199
1458
|
active = false; // never resume an old branch after a failed restore
|
|
1200
1459
|
console.warn(
|
|
@@ -1220,12 +1479,28 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1220
1479
|
};
|
|
1221
1480
|
|
|
1222
1481
|
/** Background-run result delivery: fail-closed, session-bound (A10). */
|
|
1223
|
-
const deliverBackgroundResult = async (
|
|
1482
|
+
const deliverBackgroundResult = async (workflowEngine: WorkflowEngine, runId: string): Promise<void> => {
|
|
1224
1483
|
const generation = deliveryGeneration;
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1484
|
+
const executionGeneration = workflowEngine.getState(runId)?.executionGeneration;
|
|
1485
|
+
const observerKey = `${generation}:${executionGeneration}`;
|
|
1486
|
+
if (deliveryObservers.get(runId) === observerKey) return;
|
|
1487
|
+
deliveryObservers.set(runId, observerKey);
|
|
1488
|
+
while (deliveryObservers.size > 256) {
|
|
1489
|
+
const oldest = deliveryObservers.keys().next().value;
|
|
1490
|
+
if (oldest === undefined) break;
|
|
1491
|
+
deliveryObservers.delete(oldest);
|
|
1492
|
+
}
|
|
1493
|
+
const run = await workflowEngine.waitFor(runId).catch(() => undefined);
|
|
1494
|
+
if (
|
|
1495
|
+
!run ||
|
|
1496
|
+
generation !== deliveryGeneration ||
|
|
1497
|
+
workflowEngine !== engine ||
|
|
1498
|
+
executionGeneration !== workflowEngine.getState(runId)?.executionGeneration ||
|
|
1499
|
+
!["completed", "failed", "stopped"].includes(run.status)
|
|
1500
|
+
)
|
|
1501
|
+
return;
|
|
1227
1502
|
const header = `Workflow ${run.runId} ${run.status}`;
|
|
1228
|
-
const state =
|
|
1503
|
+
const state = workflowEngine.getState(runId);
|
|
1229
1504
|
const finalResult = state?.result ?? run.finalResult;
|
|
1230
1505
|
const body =
|
|
1231
1506
|
run.error ??
|
|
@@ -1233,42 +1508,154 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1233
1508
|
? `completed with ${Object.keys(run.callResults).length} agent call(s).`
|
|
1234
1509
|
: `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
|
|
1235
1510
|
const text = `${header}: ${body}`;
|
|
1236
|
-
if (
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
} else {
|
|
1249
|
-
// No bound session endpoint — fail-closed: persist a pending marker and
|
|
1250
|
-
// flush it when a session binds, never fall back to shared pi.sendMessage.
|
|
1251
|
-
rememberPendingDelivery(runId, text);
|
|
1252
|
-
try {
|
|
1253
|
-
pi.appendEntry(PENDING_DELIVERY_TYPE, { runId, text, status: run.status, timestamp: Date.now() });
|
|
1254
|
-
} catch {
|
|
1255
|
-
// best-effort persistence; in-memory marker still ensures retry
|
|
1256
|
-
}
|
|
1511
|
+
if (!ownsDeliveryBranch()) return;
|
|
1512
|
+
rememberPendingDelivery(runId, text);
|
|
1513
|
+
try {
|
|
1514
|
+
pi.appendEntry(PENDING_DELIVERY_TYPE, {
|
|
1515
|
+
runId,
|
|
1516
|
+
text: text.slice(0, 8_000),
|
|
1517
|
+
status: run.status,
|
|
1518
|
+
timestamp: Date.now(),
|
|
1519
|
+
});
|
|
1520
|
+
advanceDeliveryAnchor();
|
|
1521
|
+
} catch {
|
|
1522
|
+
// Best-effort persistence; the in-memory marker remains retryable.
|
|
1257
1523
|
}
|
|
1524
|
+
schedulePendingDeliveries();
|
|
1258
1525
|
};
|
|
1259
1526
|
|
|
1527
|
+
const ownsDeliveryBranch = (): boolean => {
|
|
1528
|
+
if (!active || !deliveryContext || !engine || engine.isDisposed()) return false;
|
|
1529
|
+
try {
|
|
1530
|
+
const branch = deliveryContext.sessionManager.getBranch();
|
|
1531
|
+
return [deliveryBranchAnchor, journalBranchAnchor].every(
|
|
1532
|
+
(anchor) => !anchor || branch.some((entry) => entry.id === anchor),
|
|
1533
|
+
);
|
|
1534
|
+
} catch {
|
|
1535
|
+
return false;
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
const advanceDeliveryAnchor = (): void => {
|
|
1539
|
+
if (!deliveryContext) return;
|
|
1540
|
+
deliveryBranchAnchor =
|
|
1541
|
+
deliveryContext.sessionManager.getLeafId?.() ?? deliveryContext.sessionManager.getBranch().at(-1)?.id ?? null;
|
|
1542
|
+
};
|
|
1543
|
+
const canDeliver = (): boolean => {
|
|
1544
|
+
if (!ownsDeliveryBranch() || inputPreparing || compacting || navigating) return false;
|
|
1545
|
+
try {
|
|
1546
|
+
return deliveryContext?.isIdle() === true && deliveryContext.hasPendingMessages() === false;
|
|
1547
|
+
} catch {
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
|
|
1552
|
+
pendingDeliveryMarkers.clear();
|
|
1553
|
+
for (const entry of entries) {
|
|
1554
|
+
const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
1555
|
+
if (shaped.type !== "custom") continue;
|
|
1556
|
+
const data = shaped.data as Record<string, unknown> | undefined;
|
|
1557
|
+
if (typeof data?.runId !== "string") continue;
|
|
1558
|
+
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) pendingDeliveryMarkers.delete(data.runId);
|
|
1559
|
+
else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
|
|
1560
|
+
rememberPendingDelivery(
|
|
1561
|
+
data.runId,
|
|
1562
|
+
data.text,
|
|
1563
|
+
typeof data.timestamp === "number" ? data.timestamp : Date.now(),
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1260
1568
|
const flushPendingDeliveries = (): void => {
|
|
1261
|
-
if (!
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1569
|
+
if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
|
|
1570
|
+
if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
|
|
1571
|
+
const ctx = deliveryContext;
|
|
1572
|
+
const generation = deliveryGeneration;
|
|
1573
|
+
// Bounded batches coalesce simultaneous completions without model polling.
|
|
1574
|
+
const batch = [...pendingDeliveryMarkers].slice(0, 8);
|
|
1575
|
+
const isCurrent = (): boolean => generation === deliveryGeneration && ctx === deliveryContext && canDeliver();
|
|
1576
|
+
const persisted = (runId: string): boolean =>
|
|
1577
|
+
ctx.sessionManager.getBranch().some((entry) => {
|
|
1578
|
+
const shaped = entry as {
|
|
1579
|
+
type?: string;
|
|
1580
|
+
customType?: string;
|
|
1581
|
+
content?: unknown;
|
|
1582
|
+
details?: { runIds?: unknown };
|
|
1583
|
+
};
|
|
1584
|
+
const ids = shaped.details?.runIds;
|
|
1585
|
+
return (
|
|
1586
|
+
shaped.type === "custom_message" &&
|
|
1587
|
+
shaped.customType === "pi-workflows:delivery" &&
|
|
1588
|
+
typeof shaped.content === "string" &&
|
|
1589
|
+
Array.isArray(ids) &&
|
|
1590
|
+
ids.length > 0 &&
|
|
1591
|
+
ids.length <= 8 &&
|
|
1592
|
+
ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128) &&
|
|
1593
|
+
new Set(ids).size === ids.length &&
|
|
1594
|
+
ids.includes(runId)
|
|
1595
|
+
);
|
|
1596
|
+
});
|
|
1597
|
+
try {
|
|
1598
|
+
if (!isCurrent()) return;
|
|
1599
|
+
// Never use a custom-message triggerTurn: that skips input preparation
|
|
1600
|
+
// and before_agent_start. The void API is NOT an acceptance promise.
|
|
1601
|
+
const missing = batch.filter(([runId]) => !persisted(runId));
|
|
1602
|
+
if (missing.length > 0)
|
|
1603
|
+
pi.sendMessage(
|
|
1604
|
+
{
|
|
1605
|
+
content: missing.map(([, marker]) => marker.text).join("\n\n"),
|
|
1606
|
+
display: true,
|
|
1607
|
+
customType: "pi-workflows:delivery",
|
|
1608
|
+
details: { runIds: missing.map(([runId]) => runId) },
|
|
1609
|
+
},
|
|
1610
|
+
{ triggerTurn: false },
|
|
1611
|
+
);
|
|
1612
|
+
if (!isCurrent() || !batch.every(([runId]) => persisted(runId))) return;
|
|
1613
|
+
advanceDeliveryAnchor();
|
|
1614
|
+
for (const [runId] of batch) {
|
|
1615
|
+
if (!isCurrent()) return;
|
|
1616
|
+
const priorEntryIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
|
|
1265
1617
|
pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
|
|
1618
|
+
if (!isCurrent()) return;
|
|
1619
|
+
const acknowledged = ctx.sessionManager.getBranch().some((entry) => {
|
|
1620
|
+
const shaped = entry as { type?: string; customType?: string; data?: { runId?: unknown } };
|
|
1621
|
+
return (
|
|
1622
|
+
!priorEntryIds.has(entry.id) &&
|
|
1623
|
+
shaped.type === "custom" &&
|
|
1624
|
+
shaped.customType === PENDING_DELIVERY_ACK_TYPE &&
|
|
1625
|
+
shaped.data?.runId === runId
|
|
1626
|
+
);
|
|
1627
|
+
});
|
|
1628
|
+
if (!acknowledged) return;
|
|
1629
|
+
advanceDeliveryAnchor();
|
|
1266
1630
|
pendingDeliveryMarkers.delete(runId);
|
|
1267
|
-
} catch {
|
|
1268
|
-
// Keep pending until both delivery and its durable acknowledgement succeed.
|
|
1269
1631
|
}
|
|
1632
|
+
// An earlier input hook can await while the same manager navigates. Keep
|
|
1633
|
+
// exact owner evidence until our input hook consumes this private wake.
|
|
1634
|
+
if (isCurrent() && engine) {
|
|
1635
|
+
const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
|
|
1636
|
+
wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
|
|
1637
|
+
while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
|
|
1638
|
+
const oldest = wakeAdmissions.keys().next().value;
|
|
1639
|
+
if (oldest === undefined) break;
|
|
1640
|
+
wakeAdmissions.delete(oldest);
|
|
1641
|
+
}
|
|
1642
|
+
// Disable commands/templates explicitly for internal notification input.
|
|
1643
|
+
pi.sendUserMessage(wake, { deliverAs: "followUp", expandPromptTemplates: false });
|
|
1644
|
+
}
|
|
1645
|
+
} catch {
|
|
1646
|
+
// Retain unacknowledged markers. Retry only at a later lifecycle boundary.
|
|
1270
1647
|
}
|
|
1271
1648
|
};
|
|
1649
|
+
const schedulePendingDeliveries = (): void => {
|
|
1650
|
+
if (deliveryTimer || pendingDeliveryMarkers.size === 0 || !ownsDeliveryBranch()) return;
|
|
1651
|
+
const generation = deliveryGeneration;
|
|
1652
|
+
// Defer handoff out of our notification-only agent_settled handler;
|
|
1653
|
+
// later handlers may navigate or start compaction in either load order.
|
|
1654
|
+
deliveryTimer = setTimeout(() => {
|
|
1655
|
+
deliveryTimer = undefined;
|
|
1656
|
+
if (generation === deliveryGeneration) flushPendingDeliveries();
|
|
1657
|
+
}, 0);
|
|
1658
|
+
};
|
|
1272
1659
|
|
|
1273
1660
|
/** Build a script from a natural-language prompt and run it in background. */
|
|
1274
1661
|
const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
|
|
@@ -1286,7 +1673,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1286
1673
|
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1287
1674
|
return result.runId;
|
|
1288
1675
|
} catch (error: unknown) {
|
|
1289
|
-
ctx
|
|
1676
|
+
notifyWorkflow(ctx, error instanceof Error ? error.message : String(error), "error");
|
|
1290
1677
|
return undefined;
|
|
1291
1678
|
}
|
|
1292
1679
|
};
|
|
@@ -1297,10 +1684,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1297
1684
|
throw new Error(`Workflow run not found: ${runId ?? "(none)"}`);
|
|
1298
1685
|
}
|
|
1299
1686
|
saveWorkflow(name, state.run.script, sessionCwd);
|
|
1300
|
-
ctx
|
|
1687
|
+
notifyWorkflow(ctx, `Saved workflow "${name}" (${state.run.script.length} chars).`, "info");
|
|
1301
1688
|
};
|
|
1302
1689
|
|
|
1303
1690
|
pi.on("session_start", async (_event, ctx) => {
|
|
1691
|
+
turnArming.retire();
|
|
1304
1692
|
if (active || engine) return;
|
|
1305
1693
|
const generation = ++lifecycleGeneration;
|
|
1306
1694
|
sessionCwd = ctx.cwd ?? process.cwd();
|
|
@@ -1407,56 +1795,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1407
1795
|
widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
|
|
1408
1796
|
registerSurface();
|
|
1409
1797
|
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
// Use the captured function value for session affinity; if the
|
|
1420
|
-
// captured function is itself a shared accessor, the flush is still
|
|
1421
|
-
// fail-closed (delivery only when bound).
|
|
1422
|
-
(capturedSend as unknown as (message: unknown, options: unknown) => unknown).call(
|
|
1423
|
-
pi,
|
|
1424
|
-
{
|
|
1425
|
-
content: text,
|
|
1426
|
-
display: true,
|
|
1427
|
-
customType: "pi-workflows:delivery",
|
|
1428
|
-
},
|
|
1429
|
-
{ deliverAs: "followUp", triggerTurn: true },
|
|
1430
|
-
);
|
|
1431
|
-
};
|
|
1432
|
-
}
|
|
1433
|
-
} catch {
|
|
1434
|
-
// binding is best-effort; pending stays buffered
|
|
1435
|
-
}
|
|
1436
|
-
// Hydrate any pending markers persisted before this bind (e.g. runs that
|
|
1437
|
-
// settled before the first session_start) — scan the branch for
|
|
1438
|
-
// PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
|
|
1798
|
+
// Public API methods target the current session: generation + branch
|
|
1799
|
+
// ownership, not a captured function value, provide session affinity.
|
|
1800
|
+
deliveryContext = ctx;
|
|
1801
|
+
deliveryBranchAnchor = journalBranchAnchor;
|
|
1802
|
+
workflowUiBridge ??= new WorkflowUiBridge(pi.events, workflowUiSurface);
|
|
1803
|
+
workflowUiBridge.publish();
|
|
1804
|
+
inputPreparing = false;
|
|
1805
|
+
compacting = false;
|
|
1806
|
+
navigating = false;
|
|
1439
1807
|
try {
|
|
1440
|
-
|
|
1441
|
-
for (const entry of initialEntries) {
|
|
1442
|
-
const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
1443
|
-
if (shaped.type !== "custom" || typeof shaped.customType !== "string") continue;
|
|
1444
|
-
const data = shaped.data as Record<string, unknown> | undefined;
|
|
1445
|
-
const entryRunId = typeof data?.runId === "string" ? data.runId : undefined;
|
|
1446
|
-
if (!entryRunId) continue;
|
|
1447
|
-
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) {
|
|
1448
|
-
acknowledged.add(entryRunId);
|
|
1449
|
-
} else if (shaped.customType === PENDING_DELIVERY_TYPE) {
|
|
1450
|
-
const pendingText = typeof data?.text === "string" ? data.text : undefined;
|
|
1451
|
-
const timestamp = typeof data?.timestamp === "number" ? data.timestamp : Date.now();
|
|
1452
|
-
if (pendingText && !acknowledged.has(entryRunId)) rememberPendingDelivery(entryRunId, pendingText, timestamp);
|
|
1453
|
-
}
|
|
1454
|
-
}
|
|
1455
|
-
for (const runId of acknowledged) pendingDeliveryMarkers.delete(runId);
|
|
1808
|
+
hydratePendingDeliveries(ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
|
|
1456
1809
|
} catch {
|
|
1457
|
-
//
|
|
1810
|
+
pendingDeliveryMarkers.clear(); // fail closed on an unreadable branch
|
|
1458
1811
|
}
|
|
1459
|
-
|
|
1812
|
+
schedulePendingDeliveries();
|
|
1460
1813
|
|
|
1461
1814
|
// Saved workflow commands register at session_start (Pi cannot unregister
|
|
1462
1815
|
// or replace command metadata, so factory-time registration would freeze the
|
|
@@ -1482,9 +1835,9 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1482
1835
|
};
|
|
1483
1836
|
const result = await workflowEngine.start(script, options);
|
|
1484
1837
|
void deliverBackgroundResult(workflowEngine, result.runId);
|
|
1485
|
-
commandCtx
|
|
1838
|
+
notifyWorkflow(commandCtx, `Workflow ${savedName} started in background: ${result.runId}`, "info");
|
|
1486
1839
|
} catch (error: unknown) {
|
|
1487
|
-
commandCtx
|
|
1840
|
+
notifyWorkflow(commandCtx, error instanceof Error ? error.message : String(error), "error");
|
|
1488
1841
|
}
|
|
1489
1842
|
},
|
|
1490
1843
|
});
|
|
@@ -1493,10 +1846,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1493
1846
|
});
|
|
1494
1847
|
|
|
1495
1848
|
pi.on("session_shutdown", async () => {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1849
|
+
// This synchronous boundary is shared with the bus-first shutdown path.
|
|
1850
|
+
const quiescence = beginShutdownQuiesce();
|
|
1851
|
+
workflowUiBridge?.dispose();
|
|
1852
|
+
workflowUiBridge = undefined;
|
|
1500
1853
|
clearWidget();
|
|
1501
1854
|
widgetUi = undefined;
|
|
1502
1855
|
if (widgetTimer) {
|
|
@@ -1507,7 +1860,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1507
1860
|
if (closingEngine) {
|
|
1508
1861
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1509
1862
|
const result = await Promise.race([
|
|
1510
|
-
|
|
1863
|
+
quiescence,
|
|
1511
1864
|
new Promise<undefined>((resolve) => {
|
|
1512
1865
|
timer = setTimeout(() => resolve(undefined), 6_000);
|
|
1513
1866
|
}),
|
|
@@ -1529,7 +1882,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1529
1882
|
protocolProbe = undefined;
|
|
1530
1883
|
protocolInFlight = undefined;
|
|
1531
1884
|
protocolError = undefined;
|
|
1532
|
-
|
|
1885
|
+
deliveryContext = undefined;
|
|
1533
1886
|
});
|
|
1534
1887
|
}
|
|
1535
1888
|
|