@signalridge/pi-workflows 1.7.5 → 1.8.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 +16 -0
- package/README.md +145 -10
- package/package.json +2 -1
- 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 +154 -14
- package/src/errors.ts +26 -0
- package/src/index.ts +376 -172
- package/src/journal.ts +5 -0
- package/src/runtime.ts +40 -20
- package/src/safe-text.ts +4 -35
- package/src/ui.ts +38 -14
- 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
5
|
import { Text } 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";
|
|
@@ -335,6 +332,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
335
332
|
let shutdownQuiesce: Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> | undefined;
|
|
336
333
|
let lifecycleGeneration = 0;
|
|
337
334
|
let deliveryGeneration = 0;
|
|
335
|
+
const deliveryObservers = new Map<string, string>();
|
|
338
336
|
let handledTreeEvent: unknown;
|
|
339
337
|
let journalBranchAnchor: string | null | undefined;
|
|
340
338
|
let protocolAbortController: AbortController | undefined;
|
|
@@ -343,9 +341,24 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
343
341
|
let effortLevel: "off" | "high" | "ultra" = workflowSettings.effort;
|
|
344
342
|
let triggerKeyword: string | undefined = workflowSettings.keywordTriggerWord;
|
|
345
343
|
let keywordTriggerEnabled = workflowSettings.keywordTriggerEnabled;
|
|
346
|
-
/**
|
|
347
|
-
let
|
|
348
|
-
|
|
344
|
+
/** Public sends are void: acceptance is checked against the owning branch. */
|
|
345
|
+
let deliveryContext: ExtensionContext | undefined;
|
|
346
|
+
let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
347
|
+
let inputPreparing = false;
|
|
348
|
+
const turnArming = new WorkflowTurnArming();
|
|
349
|
+
let compacting = false;
|
|
350
|
+
let navigating = false;
|
|
351
|
+
let deliveryBranchAnchor: string | null | undefined;
|
|
352
|
+
const DELIVERY_WAKE_PREFIX = "[pi-workflows:wake:";
|
|
353
|
+
const DELIVERY_WAKE_TEXT = "Workflow results are ready in the preceding context.";
|
|
354
|
+
const MAX_WAKE_ADMISSIONS = 256;
|
|
355
|
+
// Private, one-use admissions survive an earlier awaited input handler, not
|
|
356
|
+
// a lifecycle retirement. Missing/evicted IDs fail closed without tombstones.
|
|
357
|
+
const wakeAdmissions = new Map<
|
|
358
|
+
string,
|
|
359
|
+
{ generation: number; context: ExtensionContext; engine: WorkflowEngine; anchor: string | null | undefined }
|
|
360
|
+
>();
|
|
361
|
+
/** Pending deliveries for completed runs, including durable recovery (A10). */
|
|
349
362
|
const pendingDeliveryMarkers = new Map<string, { text: string; timestamp: number }>();
|
|
350
363
|
const PENDING_DELIVERY_TYPE = "pi-workflows:pending-delivery";
|
|
351
364
|
const PENDING_DELIVERY_ACK_TYPE = "pi-workflows:pending-delivery-ack";
|
|
@@ -382,21 +395,46 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
382
395
|
else console.warn(`[pi-workflows] ${notice}`);
|
|
383
396
|
return { cancel: true };
|
|
384
397
|
};
|
|
398
|
+
const invalidateDelivery = (): void => {
|
|
399
|
+
deliveryGeneration++;
|
|
400
|
+
deliveryObservers.clear();
|
|
401
|
+
turnArming.retire();
|
|
402
|
+
wakeAdmissions.clear();
|
|
403
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
404
|
+
deliveryTimer = undefined;
|
|
405
|
+
};
|
|
385
406
|
const beginShutdownQuiesce = (): Promise<{ settled: boolean; pending: string[]; diagnostic?: string }> => {
|
|
386
407
|
if (shutdownQuiesce) return shutdownQuiesce;
|
|
408
|
+
// Retire admission and delivery BEFORE aborting children, including when
|
|
409
|
+
// subagents initiates shutdown first. Journal cleanup retains its separate
|
|
410
|
+
// lifecycle generation until the native shutdown hook finishes quiescence.
|
|
411
|
+
active = false;
|
|
412
|
+
invalidateDelivery();
|
|
413
|
+
deliveryContext = undefined;
|
|
387
414
|
const closingEngine = engine;
|
|
388
|
-
if (!closingEngine || closingEngine.isDisposed())
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
415
|
+
if (!closingEngine || closingEngine.isDisposed()) {
|
|
416
|
+
shutdownQuiesce = Promise.resolve({ settled: true, pending: [] });
|
|
417
|
+
return shutdownQuiesce;
|
|
418
|
+
}
|
|
419
|
+
// Publish the handshake promise before abort can synchronously re-enter
|
|
420
|
+
// the bus through an owned-child cleanup callback.
|
|
421
|
+
let settle: (result: { settled: boolean; pending: string[]; diagnostic?: string }) => void = () => {};
|
|
422
|
+
shutdownQuiesce = new Promise((resolve) => {
|
|
423
|
+
settle = resolve;
|
|
424
|
+
});
|
|
425
|
+
void closingEngine.quiesceForBranchChange().then(settle, (error: unknown) => {
|
|
426
|
+
settle({
|
|
427
|
+
settled: false,
|
|
428
|
+
pending: [],
|
|
429
|
+
diagnostic: error instanceof Error ? error.message : String(error),
|
|
430
|
+
});
|
|
431
|
+
});
|
|
394
432
|
return shutdownQuiesce;
|
|
395
433
|
};
|
|
396
434
|
// Subagents starts this handshake before retiring the owned-agent RPC. When
|
|
397
435
|
// workflows shuts down first, its native hook performs the same operation.
|
|
398
436
|
pi.events.on("pi-workflows:shutdown-quiesce", (raw: unknown) => {
|
|
399
|
-
if (!
|
|
437
|
+
if (!engine) return;
|
|
400
438
|
const respond = (raw as { respond?: unknown } | null)?.respond;
|
|
401
439
|
if (typeof respond === "function") respond(beginShutdownQuiesce());
|
|
402
440
|
});
|
|
@@ -405,6 +443,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
405
443
|
const event = (raw as { event?: unknown } | null)?.event;
|
|
406
444
|
if (!event || event === handledTreeEvent) return;
|
|
407
445
|
handledTreeEvent = event;
|
|
446
|
+
invalidateDelivery();
|
|
447
|
+
pendingDeliveryMarkers.clear();
|
|
408
448
|
engine.fenceCommittedBranch();
|
|
409
449
|
});
|
|
410
450
|
|
|
@@ -489,7 +529,21 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
489
529
|
active = true;
|
|
490
530
|
const workflowEngine = currentEngine();
|
|
491
531
|
workflowEngine.onRunSettled = notifyRunChanged;
|
|
532
|
+
workflowEngine.onBackgroundExecution = (runId) => void deliverBackgroundResult(workflowEngine, runId);
|
|
492
533
|
workflowEngine.onRuntimeEvent = notifyRunChanged;
|
|
534
|
+
if (typeof pi.registerMessageRenderer === "function") {
|
|
535
|
+
pi.registerMessageRenderer("pi-workflows:delivery", (message, { expanded }, theme) => {
|
|
536
|
+
const original =
|
|
537
|
+
typeof message.content === "string"
|
|
538
|
+
? message.content
|
|
539
|
+
: message.content
|
|
540
|
+
.filter((part) => part.type === "text")
|
|
541
|
+
.map((part) => part.text)
|
|
542
|
+
.join("\n");
|
|
543
|
+
const display = sanitizeDisplayText(original, { multiline: true });
|
|
544
|
+
return new Text(theme.fg("toolOutput", display.slice(0, expanded ? 8000 : 2000)), 0, 0);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
493
547
|
// `control("resume")` and the provider-limit retry start executions with no
|
|
494
548
|
// caller to pass options for them, and the strength table is deliberately
|
|
495
549
|
// never frozen onto a run. Without this they would resume with no table and
|
|
@@ -519,11 +573,13 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
519
573
|
],
|
|
520
574
|
parameters: WorkflowToolSchema,
|
|
521
575
|
renderCall(args, theme) {
|
|
522
|
-
const label = args.name ?? (args.script !== undefined ? "script" : "");
|
|
576
|
+
const label = sanitizeDisplayText(args.name ?? (args.script !== undefined ? "script" : ""));
|
|
523
577
|
return new Text(theme.fg("toolTitle", theme.bold(`Workflow ${label}`)), 0, 0);
|
|
524
578
|
},
|
|
525
579
|
renderResult(result, { expanded, isPartial }, theme) {
|
|
526
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
|
|
580
|
+
const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
|
|
581
|
+
multiline: true,
|
|
582
|
+
});
|
|
527
583
|
if (isPartial) return new Text(theme.fg("warning", "Workflow running…"), 0, 0);
|
|
528
584
|
const preview = expanded ? text.slice(0, 8_000) : text.split("\n").slice(0, 4).join("\n");
|
|
529
585
|
return new Text(
|
|
@@ -585,7 +641,6 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
585
641
|
replacement,
|
|
586
642
|
);
|
|
587
643
|
if (!resumed) return textResult(`Workflow run not found: ${params.resumeFromRunId}`);
|
|
588
|
-
if (resumed.background) void deliverBackgroundResult(workflowEngine, resumed.runId);
|
|
589
644
|
return textResult(formatStart(resumed), resumed);
|
|
590
645
|
}
|
|
591
646
|
const result = await workflowEngine.start(script, options);
|
|
@@ -610,10 +665,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
610
665
|
"List, inspect, pause, resume, stop, or remove workflow runs with bounded machine-readable output.",
|
|
611
666
|
parameters: ControlSchema,
|
|
612
667
|
renderCall(args, theme) {
|
|
613
|
-
return new Text(
|
|
668
|
+
return new Text(
|
|
669
|
+
theme.fg("toolTitle", theme.bold(`Workflow control: ${sanitizeDisplayText(args.action ?? "")}`)),
|
|
670
|
+
0,
|
|
671
|
+
0,
|
|
672
|
+
);
|
|
614
673
|
},
|
|
615
674
|
renderResult(result, { expanded }, theme) {
|
|
616
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : ""
|
|
675
|
+
const text = sanitizeDisplayText(result.content[0]?.type === "text" ? result.content[0].text : "", {
|
|
676
|
+
multiline: true,
|
|
677
|
+
});
|
|
617
678
|
return new Text(theme.fg("dim", expanded ? text.slice(0, 8_000) : text.slice(0, 1_000)), 0, 0);
|
|
618
679
|
},
|
|
619
680
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
@@ -950,7 +1011,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
950
1011
|
// /workflows-trigger: keyword arming (A5). Typing the bounded word
|
|
951
1012
|
// `workflow`/`workflows` — or a configured synonym — in an ordinary message
|
|
952
1013
|
// counts as an explicit opt-in to multi-agent orchestration, and the message
|
|
953
|
-
//
|
|
1014
|
+
// receives hidden model guidance. Arming AUTHORIZES the tool; it never forces a run,
|
|
954
1015
|
// and it never opens UI. A user asking a question about workflows still gets
|
|
955
1016
|
// an ordinary answer.
|
|
956
1017
|
const TRIGGER_WORD_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/u;
|
|
@@ -1003,77 +1064,66 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1003
1064
|
},
|
|
1004
1065
|
});
|
|
1005
1066
|
|
|
1006
|
-
// Keyword arming input hook (A5).
|
|
1007
|
-
//
|
|
1067
|
+
// Keyword arming input hook (A5). Record provenance, never append model
|
|
1068
|
+
// guidance to native user text: the model may use `workflow` and may still
|
|
1008
1069
|
// decline, which is what keeps "how do workflows work?" an ordinary
|
|
1009
1070
|
// question. Nothing is swallowed and no UI opens.
|
|
1010
|
-
pi.on("input", (event) => {
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1071
|
+
pi.on("input", (event, ctx) => {
|
|
1072
|
+
const ownWake = event.source === "extension" && event.text.startsWith(DELIVERY_WAKE_PREFIX);
|
|
1073
|
+
if (ownWake) {
|
|
1074
|
+
const admission = wakeAdmissions.get(event.text);
|
|
1075
|
+
wakeAdmissions.delete(event.text); // exact, one-use ownership, even on rejection
|
|
1076
|
+
if (
|
|
1077
|
+
!admission ||
|
|
1078
|
+
admission.generation !== deliveryGeneration ||
|
|
1079
|
+
admission.context !== deliveryContext ||
|
|
1080
|
+
admission.engine !== engine ||
|
|
1081
|
+
!canDeliver()
|
|
1082
|
+
) {
|
|
1083
|
+
turnArming.retire();
|
|
1084
|
+
return { action: "handled" as const };
|
|
1085
|
+
}
|
|
1086
|
+
try {
|
|
1087
|
+
if (
|
|
1088
|
+
admission.anchor &&
|
|
1089
|
+
!admission.context.sessionManager.getBranch().some((entry) => entry.id === admission.anchor)
|
|
1090
|
+
) {
|
|
1091
|
+
turnArming.retire();
|
|
1092
|
+
return { action: "handled" as const };
|
|
1093
|
+
}
|
|
1094
|
+
} catch {
|
|
1095
|
+
turnArming.retire();
|
|
1096
|
+
return { action: "handled" as const };
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
inputPreparing = true;
|
|
1100
|
+
navigating = false; // a fresh prompt supersedes an uncommitted navigation
|
|
1101
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1102
|
+
deliveryTimer = undefined;
|
|
1103
|
+
const text = event.text;
|
|
1104
|
+
const explicitTrigger = keywordTriggerEnabled && hasTriggerWord(text, triggerKeyword);
|
|
1105
|
+
const substantive = effortLevel !== "off" && text.trim().length >= 16 && !text.trim().startsWith("/");
|
|
1106
|
+
turnArming.observe(
|
|
1107
|
+
{ ...event, text },
|
|
1108
|
+
ctx,
|
|
1109
|
+
active && (explicitTrigger || substantive),
|
|
1110
|
+
effortLevel,
|
|
1111
|
+
explicitTrigger,
|
|
1112
|
+
);
|
|
1113
|
+
if (ownWake)
|
|
1014
1114
|
return {
|
|
1015
1115
|
action: "transform" as const,
|
|
1016
|
-
text:
|
|
1116
|
+
text: DELIVERY_WAKE_TEXT,
|
|
1017
1117
|
...(event.images ? { images: event.images } : {}),
|
|
1018
1118
|
};
|
|
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
|
-
};
|
|
1119
|
+
return { action: "continue" as const };
|
|
1035
1120
|
});
|
|
1036
1121
|
|
|
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
|
-
});
|
|
1122
|
+
// Hidden guidance is turn-local, including on reload and after later hooks
|
|
1123
|
+
// navigate while before_agent_start is awaiting. Never edit native history.
|
|
1124
|
+
// Request-local overlays never enter native history or summarization.
|
|
1125
|
+
pi.on("message_start", (event, ctx) => turnArming.messageStarted(event, ctx));
|
|
1126
|
+
pi.on("context", (event, ctx) => turnArming.filterContext(event, ctx));
|
|
1077
1127
|
|
|
1078
1128
|
// Standing effort mode mirrors the reference: it auto-arms substantive
|
|
1079
1129
|
// interactive messages but remains guidance, not an inferred spend ceiling.
|
|
@@ -1171,8 +1221,64 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1171
1221
|
|
|
1172
1222
|
// Neither before-event is a commit point. Pi can veto in a later handler,
|
|
1173
1223
|
// or summarization may abort after every handler has run.
|
|
1174
|
-
|
|
1175
|
-
|
|
1224
|
+
const beforeNavigation = (ctx: ExtensionContext): { cancel: true } | undefined => {
|
|
1225
|
+
turnArming.retire();
|
|
1226
|
+
const veto = navigationPreflight(ctx);
|
|
1227
|
+
if (!veto) {
|
|
1228
|
+
navigating = true;
|
|
1229
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1230
|
+
deliveryTimer = undefined;
|
|
1231
|
+
}
|
|
1232
|
+
return veto;
|
|
1233
|
+
};
|
|
1234
|
+
pi.on("session_before_switch", (_event, ctx) => beforeNavigation(ctx));
|
|
1235
|
+
pi.on("session_before_tree", (event, ctx) => {
|
|
1236
|
+
// The public preparation array is mutable; remove only legacy owned
|
|
1237
|
+
// custom guidance, never quoted user content or stored history.
|
|
1238
|
+
const entries = event.preparation?.entriesToSummarize ?? [];
|
|
1239
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
1240
|
+
const entry = entries[index];
|
|
1241
|
+
if (entry?.type === "custom_message" && entry.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
|
|
1242
|
+
entries.splice(index, 1);
|
|
1243
|
+
}
|
|
1244
|
+
return beforeNavigation(ctx);
|
|
1245
|
+
});
|
|
1246
|
+
pi.on("session_before_fork", (_event, ctx) => beforeNavigation(ctx));
|
|
1247
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1248
|
+
inputPreparing = true;
|
|
1249
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1250
|
+
deliveryTimer = undefined;
|
|
1251
|
+
return turnArming.consume(event, ctx);
|
|
1252
|
+
});
|
|
1253
|
+
pi.on("agent_start", () => {
|
|
1254
|
+
inputPreparing = false;
|
|
1255
|
+
});
|
|
1256
|
+
pi.on("session_before_compact", (event) => {
|
|
1257
|
+
for (const messages of [
|
|
1258
|
+
event.preparation?.messagesToSummarize ?? [],
|
|
1259
|
+
event.preparation?.turnPrefixMessages ?? [],
|
|
1260
|
+
]) {
|
|
1261
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
1262
|
+
const message = messages[index];
|
|
1263
|
+
if (message?.role === "custom" && message.customType === WORKFLOW_ARMING_MESSAGE_TYPE)
|
|
1264
|
+
messages.splice(index, 1);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
compacting = true;
|
|
1268
|
+
if (deliveryTimer) clearTimeout(deliveryTimer);
|
|
1269
|
+
deliveryTimer = undefined;
|
|
1270
|
+
});
|
|
1271
|
+
const afterCompact = (): void => {
|
|
1272
|
+
compacting = false;
|
|
1273
|
+
schedulePendingDeliveries();
|
|
1274
|
+
};
|
|
1275
|
+
pi.on("session_compact", afterCompact);
|
|
1276
|
+
pi.on("session_compact_failed", afterCompact);
|
|
1277
|
+
pi.on("agent_settled", () => {
|
|
1278
|
+
turnArming.retire();
|
|
1279
|
+
inputPreparing = false;
|
|
1280
|
+
schedulePendingDeliveries();
|
|
1281
|
+
});
|
|
1176
1282
|
pi.on("session_tree", (event, ctx) => {
|
|
1177
1283
|
let workflowEngine: WorkflowEngine;
|
|
1178
1284
|
try {
|
|
@@ -1180,6 +1286,11 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1180
1286
|
} catch {
|
|
1181
1287
|
return;
|
|
1182
1288
|
}
|
|
1289
|
+
invalidateDelivery();
|
|
1290
|
+
pendingDeliveryMarkers.clear();
|
|
1291
|
+
inputPreparing = false;
|
|
1292
|
+
compacting = false;
|
|
1293
|
+
navigating = false;
|
|
1183
1294
|
if (handledTreeEvent !== event) {
|
|
1184
1295
|
// A controller abort can synchronously call stop-owned RPC. Detach the
|
|
1185
1296
|
// old subagent records first, regardless of extension load order, so its
|
|
@@ -1188,13 +1299,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1188
1299
|
workflowEngine.fenceCommittedBranch();
|
|
1189
1300
|
}
|
|
1190
1301
|
handledTreeEvent = event;
|
|
1191
|
-
deliveryGeneration++;
|
|
1192
1302
|
try {
|
|
1193
1303
|
const entries = ctx.sessionManager.getBranch() as unknown as SessionEntryLike[];
|
|
1194
1304
|
journalBranchAnchor =
|
|
1195
1305
|
ctx.sessionManager.getLeafId?.() ?? (entries.at(-1) as { id?: string } | undefined)?.id ?? null;
|
|
1196
1306
|
workflowEngine.restore(entries);
|
|
1197
1307
|
workflowEngine.resumeLifecycle();
|
|
1308
|
+
deliveryContext = ctx;
|
|
1309
|
+
deliveryBranchAnchor = journalBranchAnchor;
|
|
1310
|
+
hydratePendingDeliveries(entries);
|
|
1311
|
+
schedulePendingDeliveries();
|
|
1198
1312
|
} catch (error: unknown) {
|
|
1199
1313
|
active = false; // never resume an old branch after a failed restore
|
|
1200
1314
|
console.warn(
|
|
@@ -1220,12 +1334,28 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1220
1334
|
};
|
|
1221
1335
|
|
|
1222
1336
|
/** Background-run result delivery: fail-closed, session-bound (A10). */
|
|
1223
|
-
const deliverBackgroundResult = async (
|
|
1337
|
+
const deliverBackgroundResult = async (workflowEngine: WorkflowEngine, runId: string): Promise<void> => {
|
|
1224
1338
|
const generation = deliveryGeneration;
|
|
1225
|
-
const
|
|
1226
|
-
|
|
1339
|
+
const executionGeneration = workflowEngine.getState(runId)?.executionGeneration;
|
|
1340
|
+
const observerKey = `${generation}:${executionGeneration}`;
|
|
1341
|
+
if (deliveryObservers.get(runId) === observerKey) return;
|
|
1342
|
+
deliveryObservers.set(runId, observerKey);
|
|
1343
|
+
while (deliveryObservers.size > 256) {
|
|
1344
|
+
const oldest = deliveryObservers.keys().next().value;
|
|
1345
|
+
if (oldest === undefined) break;
|
|
1346
|
+
deliveryObservers.delete(oldest);
|
|
1347
|
+
}
|
|
1348
|
+
const run = await workflowEngine.waitFor(runId).catch(() => undefined);
|
|
1349
|
+
if (
|
|
1350
|
+
!run ||
|
|
1351
|
+
generation !== deliveryGeneration ||
|
|
1352
|
+
workflowEngine !== engine ||
|
|
1353
|
+
executionGeneration !== workflowEngine.getState(runId)?.executionGeneration ||
|
|
1354
|
+
!["completed", "failed", "stopped"].includes(run.status)
|
|
1355
|
+
)
|
|
1356
|
+
return;
|
|
1227
1357
|
const header = `Workflow ${run.runId} ${run.status}`;
|
|
1228
|
-
const state =
|
|
1358
|
+
const state = workflowEngine.getState(runId);
|
|
1229
1359
|
const finalResult = state?.result ?? run.finalResult;
|
|
1230
1360
|
const body =
|
|
1231
1361
|
run.error ??
|
|
@@ -1233,42 +1363,154 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1233
1363
|
? `completed with ${Object.keys(run.callResults).length} agent call(s).`
|
|
1234
1364
|
: `result:\n${typeof finalResult === "string" ? finalResult : (JSON.stringify(finalResult, null, 2) ?? String(finalResult))}`);
|
|
1235
1365
|
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
|
-
}
|
|
1366
|
+
if (!ownsDeliveryBranch()) return;
|
|
1367
|
+
rememberPendingDelivery(runId, text);
|
|
1368
|
+
try {
|
|
1369
|
+
pi.appendEntry(PENDING_DELIVERY_TYPE, {
|
|
1370
|
+
runId,
|
|
1371
|
+
text: text.slice(0, 8_000),
|
|
1372
|
+
status: run.status,
|
|
1373
|
+
timestamp: Date.now(),
|
|
1374
|
+
});
|
|
1375
|
+
advanceDeliveryAnchor();
|
|
1376
|
+
} catch {
|
|
1377
|
+
// Best-effort persistence; the in-memory marker remains retryable.
|
|
1257
1378
|
}
|
|
1379
|
+
schedulePendingDeliveries();
|
|
1258
1380
|
};
|
|
1259
1381
|
|
|
1382
|
+
const ownsDeliveryBranch = (): boolean => {
|
|
1383
|
+
if (!active || !deliveryContext || !engine || engine.isDisposed()) return false;
|
|
1384
|
+
try {
|
|
1385
|
+
const branch = deliveryContext.sessionManager.getBranch();
|
|
1386
|
+
return [deliveryBranchAnchor, journalBranchAnchor].every(
|
|
1387
|
+
(anchor) => !anchor || branch.some((entry) => entry.id === anchor),
|
|
1388
|
+
);
|
|
1389
|
+
} catch {
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
const advanceDeliveryAnchor = (): void => {
|
|
1394
|
+
if (!deliveryContext) return;
|
|
1395
|
+
deliveryBranchAnchor =
|
|
1396
|
+
deliveryContext.sessionManager.getLeafId?.() ?? deliveryContext.sessionManager.getBranch().at(-1)?.id ?? null;
|
|
1397
|
+
};
|
|
1398
|
+
const canDeliver = (): boolean => {
|
|
1399
|
+
if (!ownsDeliveryBranch() || inputPreparing || compacting || navigating) return false;
|
|
1400
|
+
try {
|
|
1401
|
+
return deliveryContext?.isIdle() === true && deliveryContext.hasPendingMessages() === false;
|
|
1402
|
+
} catch {
|
|
1403
|
+
return false;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
const hydratePendingDeliveries = (entries: SessionEntryLike[]): void => {
|
|
1407
|
+
pendingDeliveryMarkers.clear();
|
|
1408
|
+
for (const entry of entries) {
|
|
1409
|
+
const shaped = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
1410
|
+
if (shaped.type !== "custom") continue;
|
|
1411
|
+
const data = shaped.data as Record<string, unknown> | undefined;
|
|
1412
|
+
if (typeof data?.runId !== "string") continue;
|
|
1413
|
+
if (shaped.customType === PENDING_DELIVERY_ACK_TYPE) pendingDeliveryMarkers.delete(data.runId);
|
|
1414
|
+
else if (shaped.customType === PENDING_DELIVERY_TYPE && typeof data.text === "string") {
|
|
1415
|
+
rememberPendingDelivery(
|
|
1416
|
+
data.runId,
|
|
1417
|
+
data.text,
|
|
1418
|
+
typeof data.timestamp === "number" ? data.timestamp : Date.now(),
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1260
1423
|
const flushPendingDeliveries = (): void => {
|
|
1261
|
-
if (!
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1424
|
+
if (!canDeliver() || !deliveryContext || pendingDeliveryMarkers.size === 0) return;
|
|
1425
|
+
if (typeof pi.sendMessage !== "function" || typeof pi.sendUserMessage !== "function") return;
|
|
1426
|
+
const ctx = deliveryContext;
|
|
1427
|
+
const generation = deliveryGeneration;
|
|
1428
|
+
// Bounded batches coalesce simultaneous completions without model polling.
|
|
1429
|
+
const batch = [...pendingDeliveryMarkers].slice(0, 8);
|
|
1430
|
+
const isCurrent = (): boolean => generation === deliveryGeneration && ctx === deliveryContext && canDeliver();
|
|
1431
|
+
const persisted = (runId: string): boolean =>
|
|
1432
|
+
ctx.sessionManager.getBranch().some((entry) => {
|
|
1433
|
+
const shaped = entry as {
|
|
1434
|
+
type?: string;
|
|
1435
|
+
customType?: string;
|
|
1436
|
+
content?: unknown;
|
|
1437
|
+
details?: { runIds?: unknown };
|
|
1438
|
+
};
|
|
1439
|
+
const ids = shaped.details?.runIds;
|
|
1440
|
+
return (
|
|
1441
|
+
shaped.type === "custom_message" &&
|
|
1442
|
+
shaped.customType === "pi-workflows:delivery" &&
|
|
1443
|
+
typeof shaped.content === "string" &&
|
|
1444
|
+
Array.isArray(ids) &&
|
|
1445
|
+
ids.length > 0 &&
|
|
1446
|
+
ids.length <= 8 &&
|
|
1447
|
+
ids.every((id) => typeof id === "string" && id.length > 0 && id.length <= 128) &&
|
|
1448
|
+
new Set(ids).size === ids.length &&
|
|
1449
|
+
ids.includes(runId)
|
|
1450
|
+
);
|
|
1451
|
+
});
|
|
1452
|
+
try {
|
|
1453
|
+
if (!isCurrent()) return;
|
|
1454
|
+
// Never use a custom-message triggerTurn: that skips input preparation
|
|
1455
|
+
// and before_agent_start. The void API is NOT an acceptance promise.
|
|
1456
|
+
const missing = batch.filter(([runId]) => !persisted(runId));
|
|
1457
|
+
if (missing.length > 0)
|
|
1458
|
+
pi.sendMessage(
|
|
1459
|
+
{
|
|
1460
|
+
content: missing.map(([, marker]) => marker.text).join("\n\n"),
|
|
1461
|
+
display: true,
|
|
1462
|
+
customType: "pi-workflows:delivery",
|
|
1463
|
+
details: { runIds: missing.map(([runId]) => runId) },
|
|
1464
|
+
},
|
|
1465
|
+
{ triggerTurn: false },
|
|
1466
|
+
);
|
|
1467
|
+
if (!isCurrent() || !batch.every(([runId]) => persisted(runId))) return;
|
|
1468
|
+
advanceDeliveryAnchor();
|
|
1469
|
+
for (const [runId] of batch) {
|
|
1470
|
+
if (!isCurrent()) return;
|
|
1471
|
+
const priorEntryIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
|
|
1265
1472
|
pi.appendEntry(PENDING_DELIVERY_ACK_TYPE, { runId, timestamp: Date.now() });
|
|
1473
|
+
if (!isCurrent()) return;
|
|
1474
|
+
const acknowledged = ctx.sessionManager.getBranch().some((entry) => {
|
|
1475
|
+
const shaped = entry as { type?: string; customType?: string; data?: { runId?: unknown } };
|
|
1476
|
+
return (
|
|
1477
|
+
!priorEntryIds.has(entry.id) &&
|
|
1478
|
+
shaped.type === "custom" &&
|
|
1479
|
+
shaped.customType === PENDING_DELIVERY_ACK_TYPE &&
|
|
1480
|
+
shaped.data?.runId === runId
|
|
1481
|
+
);
|
|
1482
|
+
});
|
|
1483
|
+
if (!acknowledged) return;
|
|
1484
|
+
advanceDeliveryAnchor();
|
|
1266
1485
|
pendingDeliveryMarkers.delete(runId);
|
|
1267
|
-
} catch {
|
|
1268
|
-
// Keep pending until both delivery and its durable acknowledgement succeed.
|
|
1269
1486
|
}
|
|
1487
|
+
// An earlier input hook can await while the same manager navigates. Keep
|
|
1488
|
+
// exact owner evidence until our input hook consumes this private wake.
|
|
1489
|
+
if (isCurrent() && engine) {
|
|
1490
|
+
const wake = `${DELIVERY_WAKE_PREFIX}${randomUUID()}] ${DELIVERY_WAKE_TEXT}`;
|
|
1491
|
+
wakeAdmissions.set(wake, { generation, context: ctx, engine, anchor: deliveryBranchAnchor });
|
|
1492
|
+
while (wakeAdmissions.size > MAX_WAKE_ADMISSIONS) {
|
|
1493
|
+
const oldest = wakeAdmissions.keys().next().value;
|
|
1494
|
+
if (oldest === undefined) break;
|
|
1495
|
+
wakeAdmissions.delete(oldest);
|
|
1496
|
+
}
|
|
1497
|
+
// Disable commands/templates explicitly for internal notification input.
|
|
1498
|
+
pi.sendUserMessage(wake, { deliverAs: "followUp", expandPromptTemplates: false });
|
|
1499
|
+
}
|
|
1500
|
+
} catch {
|
|
1501
|
+
// Retain unacknowledged markers. Retry only at a later lifecycle boundary.
|
|
1270
1502
|
}
|
|
1271
1503
|
};
|
|
1504
|
+
const schedulePendingDeliveries = (): void => {
|
|
1505
|
+
if (deliveryTimer || pendingDeliveryMarkers.size === 0 || !ownsDeliveryBranch()) return;
|
|
1506
|
+
const generation = deliveryGeneration;
|
|
1507
|
+
// Defer handoff out of our notification-only agent_settled handler;
|
|
1508
|
+
// later handlers may navigate or start compaction in either load order.
|
|
1509
|
+
deliveryTimer = setTimeout(() => {
|
|
1510
|
+
deliveryTimer = undefined;
|
|
1511
|
+
if (generation === deliveryGeneration) flushPendingDeliveries();
|
|
1512
|
+
}, 0);
|
|
1513
|
+
};
|
|
1272
1514
|
|
|
1273
1515
|
/** Build a script from a natural-language prompt and run it in background. */
|
|
1274
1516
|
const runWorkflowFromPrompt = async (ctx: ExtensionCommandContext, prompt: string): Promise<string | undefined> => {
|
|
@@ -1301,6 +1543,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1301
1543
|
};
|
|
1302
1544
|
|
|
1303
1545
|
pi.on("session_start", async (_event, ctx) => {
|
|
1546
|
+
turnArming.retire();
|
|
1304
1547
|
if (active || engine) return;
|
|
1305
1548
|
const generation = ++lifecycleGeneration;
|
|
1306
1549
|
sessionCwd = ctx.cwd ?? process.cwd();
|
|
@@ -1407,56 +1650,19 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1407
1650
|
widgetUi = ctx.mode === "tui" ? ctx.ui : undefined;
|
|
1408
1651
|
registerSurface();
|
|
1409
1652
|
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1653
|
+
// Public API methods target the current session: generation + branch
|
|
1654
|
+
// ownership, not a captured function value, provide session affinity.
|
|
1655
|
+
deliveryContext = ctx;
|
|
1656
|
+
deliveryBranchAnchor = journalBranchAnchor;
|
|
1657
|
+
inputPreparing = false;
|
|
1658
|
+
compacting = false;
|
|
1659
|
+
navigating = false;
|
|
1415
1660
|
try {
|
|
1416
|
-
|
|
1417
|
-
if (typeof capturedSend === "function") {
|
|
1418
|
-
deliverResult = (text: string) => {
|
|
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
|
-
}
|
|
1661
|
+
hydratePendingDeliveries(ctx.sessionManager.getBranch() as unknown as SessionEntryLike[]);
|
|
1433
1662
|
} catch {
|
|
1434
|
-
//
|
|
1663
|
+
pendingDeliveryMarkers.clear(); // fail closed on an unreadable branch
|
|
1435
1664
|
}
|
|
1436
|
-
|
|
1437
|
-
// settled before the first session_start) — scan the branch for
|
|
1438
|
-
// PENDING_DELIVERY_TYPE entries that have no in-memory counterpart.
|
|
1439
|
-
try {
|
|
1440
|
-
const acknowledged = new Set<string>();
|
|
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);
|
|
1456
|
-
} catch {
|
|
1457
|
-
// scan is best-effort
|
|
1458
|
-
}
|
|
1459
|
-
flushPendingDeliveries();
|
|
1665
|
+
schedulePendingDeliveries();
|
|
1460
1666
|
|
|
1461
1667
|
// Saved workflow commands register at session_start (Pi cannot unregister
|
|
1462
1668
|
// or replace command metadata, so factory-time registration would freeze the
|
|
@@ -1493,10 +1699,8 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1493
1699
|
});
|
|
1494
1700
|
|
|
1495
1701
|
pi.on("session_shutdown", async () => {
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
// engine quiescence; the old session must not accept another run.
|
|
1499
|
-
active = false;
|
|
1702
|
+
// This synchronous boundary is shared with the bus-first shutdown path.
|
|
1703
|
+
const quiescence = beginShutdownQuiesce();
|
|
1500
1704
|
clearWidget();
|
|
1501
1705
|
widgetUi = undefined;
|
|
1502
1706
|
if (widgetTimer) {
|
|
@@ -1507,7 +1711,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1507
1711
|
if (closingEngine) {
|
|
1508
1712
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1509
1713
|
const result = await Promise.race([
|
|
1510
|
-
|
|
1714
|
+
quiescence,
|
|
1511
1715
|
new Promise<undefined>((resolve) => {
|
|
1512
1716
|
timer = setTimeout(() => resolve(undefined), 6_000);
|
|
1513
1717
|
}),
|
|
@@ -1529,7 +1733,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
|
|
|
1529
1733
|
protocolProbe = undefined;
|
|
1530
1734
|
protocolInFlight = undefined;
|
|
1531
1735
|
protocolError = undefined;
|
|
1532
|
-
|
|
1736
|
+
deliveryContext = undefined;
|
|
1533
1737
|
});
|
|
1534
1738
|
}
|
|
1535
1739
|
|