@bastani/atomic 0.8.14-0 → 0.8.15-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.
Files changed (50) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +0 -8
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/CHANGELOG.md +3 -0
  5. package/dist/builtin/mcp/index.ts +4 -8
  6. package/dist/builtin/mcp/package.json +1 -1
  7. package/dist/builtin/subagents/package.json +1 -1
  8. package/dist/builtin/subagents/skills/tmux/SKILL.md +220 -0
  9. package/dist/builtin/subagents/skills/tmux/scripts/find-sessions.sh +112 -0
  10. package/dist/builtin/subagents/skills/tmux/scripts/wait-for-text.sh +83 -0
  11. package/dist/builtin/web-access/package.json +1 -1
  12. package/dist/builtin/workflows/CHANGELOG.md +10 -1
  13. package/dist/builtin/workflows/README.md +3 -1
  14. package/dist/builtin/workflows/builtin/ralph.ts +222 -295
  15. package/dist/builtin/workflows/package.json +1 -1
  16. package/dist/builtin/workflows/src/extension/background-ui-adapter.ts +20 -11
  17. package/dist/builtin/workflows/src/extension/index.ts +1 -0
  18. package/dist/builtin/workflows/src/extension/status-writer.ts +18 -3
  19. package/dist/builtin/workflows/src/runs/background/runner.ts +8 -10
  20. package/dist/builtin/workflows/src/runs/foreground/executor.ts +484 -91
  21. package/dist/builtin/workflows/src/runs/foreground/stage-control-registry.ts +13 -2
  22. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +41 -15
  23. package/dist/builtin/workflows/src/runs/shared/graph-inference.ts +31 -0
  24. package/dist/builtin/workflows/src/runs/shared/prompt-callsite.ts +98 -0
  25. package/dist/builtin/workflows/src/shared/persistence-restore.ts +3 -1
  26. package/dist/builtin/workflows/src/shared/persistence-session-entries.ts +4 -0
  27. package/dist/builtin/workflows/src/shared/store-types.ts +12 -1
  28. package/dist/builtin/workflows/src/shared/store.ts +77 -3
  29. package/dist/builtin/workflows/src/tui/graph-view.ts +17 -1
  30. package/dist/builtin/workflows/src/tui/prompt-card.ts +185 -30
  31. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +386 -21
  32. package/docs/changelog.mdx +41 -14
  33. package/docs/docs.json +1 -0
  34. package/docs/extensions.md +19 -19
  35. package/docs/images/workflow-input-picker.png +0 -0
  36. package/docs/images/workflow-list.png +0 -0
  37. package/docs/index.md +33 -27
  38. package/docs/providers.md +2 -2
  39. package/docs/quickstart.md +15 -15
  40. package/docs/sdk.md +8 -8
  41. package/docs/sessions.md +5 -5
  42. package/docs/settings.md +27 -1
  43. package/docs/skills.md +2 -2
  44. package/docs/subagents.md +157 -0
  45. package/docs/usage.md +7 -7
  46. package/docs/windows.md +8 -0
  47. package/docs/workflows.md +62 -9
  48. package/package.json +2 -1
  49. package/docs/images/doom-extension.png +0 -0
  50. package/docs/images/exy.png +0 -3
@@ -2,6 +2,7 @@
2
2
  * Main DAG executor: run(def, inputs, opts) → RunResult
3
3
  */
4
4
 
5
+ import { createHash } from "node:crypto";
5
6
  import { mkdir, writeFile } from "node:fs/promises";
6
7
  import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
7
8
  import { CONFIG_DIR_NAME } from "@bastani/atomic";
@@ -40,6 +41,8 @@ import type {
40
41
  RunSnapshot,
41
42
  WorkflowOverlayAdapter,
42
43
  WorkflowFailureKind,
44
+ PendingPrompt,
45
+ PromptKind,
43
46
  } from "../../shared/store-types.js";
44
47
  import type { StageControlHandle, StageControlRegistry, AgentSessionEventListener } from "./stage-control-registry.js";
45
48
  import type { Store } from "../../shared/store.js";
@@ -68,6 +71,7 @@ import {
68
71
  import { validateWorkflowModels } from "../shared/model-fallback.js";
69
72
  import type { WorkflowFailure } from "../../shared/workflow-failures.js";
70
73
  import { classifyWorkflowFailure } from "../../shared/workflow-failures.js";
74
+ import { selectPromptCallsiteFrame } from "../shared/prompt-callsite.js";
71
75
 
72
76
  export interface ResolvedInputs extends Record<string, unknown> {}
73
77
 
@@ -80,6 +84,8 @@ export interface RunOpts {
80
84
  adapters?: StageAdapters;
81
85
  /** HIL adapter injected by the pi runtime or test harness. */
82
86
  ui?: WorkflowUIAdapter;
87
+ /** Internal detached-run mode: surface ctx.ui.* as node-local workflow prompt stages. */
88
+ usePromptNodesForUi?: boolean;
83
89
  /** Store override (for testing; defaults to singleton store) */
84
90
  store?: Store;
85
91
  /** Persistence port for writing session entries (run.start, stage.start, etc.). */
@@ -184,6 +190,69 @@ function resolveInputConcurrency(
184
190
  // HIL unavailable fallback — rejects with precise per-primitive error
185
191
  // ---------------------------------------------------------------------------
186
192
 
193
+ interface PromptDescriptor {
194
+ readonly kind: PromptKind;
195
+ readonly message: string;
196
+ readonly choices?: readonly string[];
197
+ readonly initial?: string;
198
+ }
199
+
200
+ function fallbackForPromptDescriptor(descriptor: PromptDescriptor): unknown {
201
+ switch (descriptor.kind) {
202
+ case "input":
203
+ case "editor":
204
+ return descriptor.initial ?? "";
205
+ case "confirm":
206
+ return false;
207
+ case "select":
208
+ return descriptor.choices?.[0] ?? "";
209
+ }
210
+ }
211
+
212
+ function makePrompt(descriptor: PromptDescriptor): PendingPrompt {
213
+ return {
214
+ id: `hil-${crypto.randomUUID()}`,
215
+ kind: descriptor.kind,
216
+ message: descriptor.message,
217
+ ...(descriptor.choices !== undefined ? { choices: descriptor.choices } : {}),
218
+ ...(descriptor.initial !== undefined ? { initial: descriptor.initial } : {}),
219
+ createdAt: Date.now(),
220
+ };
221
+ }
222
+
223
+ function stableHash(value: unknown): string {
224
+ // 128 bits is plenty for replay-key identity while keeping graph labels compact.
225
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 32);
226
+ }
227
+
228
+ function promptDescriptorHash(descriptor: PromptDescriptor): string {
229
+ return stableHash({
230
+ kind: descriptor.kind,
231
+ message: descriptor.message,
232
+ choices: descriptor.choices ?? [],
233
+ // Include input/editor initial text because it is visible prompt context;
234
+ // changing it should not replay a stale answer from the same callsite.
235
+ initial: descriptor.initial ?? null,
236
+ });
237
+ }
238
+
239
+ function promptReplayKey(descriptor: PromptDescriptor): string {
240
+ return `prompt:${descriptor.kind}:${promptDescriptorHash(descriptor)}:${promptCallsiteHash()}`;
241
+ }
242
+
243
+ function promptCallsiteHash(): string {
244
+ // Capturing an Error stack is intentional here: HIL prompts are an
245
+ // interactive slow path, and the author callsite is part of the replay key.
246
+ const frame = selectPromptCallsiteFrame(new Error().stack ?? "") ?? "unknown";
247
+ return stableHash(frame);
248
+ }
249
+
250
+ function hilAbortError(signal: AbortSignal): Error {
251
+ return signal.reason instanceof Error
252
+ ? signal.reason
253
+ : new Error("pi-workflows: HIL aborted");
254
+ }
255
+
187
256
  function makeUnavailableUIContext(): WorkflowUIContext {
188
257
  const msg = (primitive: string): string =>
189
258
  `pi-workflows: HIL ctx.ui.${primitive} is unavailable because pi runtime did not provide a UI adapter`;
@@ -196,8 +265,8 @@ function makeUnavailableUIContext(): WorkflowUIContext {
196
265
  }
197
266
 
198
267
  type AskUserQuestionToolEvent =
199
- | { phase: "start"; callId: string }
200
- | { phase: "end"; callId: string; nameMatched: boolean };
268
+ | { phase: "start"; callId?: string }
269
+ | { phase: "end"; callId?: string; nameMatched: boolean };
201
270
 
202
271
  function stringField(value: Record<string, unknown>, keys: readonly string[]): string | undefined {
203
272
  for (const key of keys) {
@@ -217,7 +286,7 @@ function askUserQuestionToolEvent(event: unknown): AskUserQuestionToolEvent | un
217
286
  const record = event as Record<string, unknown>;
218
287
  const type = typeof record["type"] === "string" ? record["type"] : "";
219
288
  const toolName = stringField(record, ["toolName", "tool_name", "name"]);
220
- const callId = stringField(record, ["toolCallId", "tool_call_id", "toolUseId", "tool_use_id", "id"]) ?? "__ask_user_question__";
289
+ const callId = stringField(record, ["toolCallId", "tool_call_id", "toolUseId", "tool_use_id", "id"]);
221
290
 
222
291
  if (type === "tool_execution_start" && isAskUserQuestionToolName(toolName)) {
223
292
  return { phase: "start", callId };
@@ -1094,12 +1163,52 @@ function runFailureMetadata(err: unknown, stages: readonly StageSnapshot[]): Run
1094
1163
  };
1095
1164
  }
1096
1165
 
1166
+ function stageReplayFields(stage: StageSnapshot): Partial<Pick<StageSnapshot, "replayKey" | "replayedFromStageId" | "replayed">> {
1167
+ return {
1168
+ ...(stage.replayKey !== undefined ? { replayKey: stage.replayKey } : {}),
1169
+ ...(stage.replayedFromStageId !== undefined ? { replayedFromStageId: stage.replayedFromStageId } : {}),
1170
+ ...(stage.replayed !== undefined ? { replayed: stage.replayed } : {}),
1171
+ };
1172
+ }
1173
+
1174
+ type PromptAnswerReplaySafety = "allowed" | "unavailable" | "ambiguous";
1175
+
1176
+ function getPromptAnswerState(
1177
+ hasReplayAnswer: boolean,
1178
+ replaySourceId: string | undefined,
1179
+ answerReplay: PromptAnswerReplaySafety,
1180
+ ): StageSnapshot["promptAnswerState"] {
1181
+ if (replaySourceId === undefined) return undefined;
1182
+ if (hasReplayAnswer) return "available";
1183
+ if (answerReplay === "ambiguous") return "ambiguous";
1184
+ return "unavailable";
1185
+ }
1186
+
1097
1187
  type ContinuationReplayDecision =
1098
- | { readonly kind: "execute"; readonly source?: StageSnapshot }
1099
- | { readonly kind: "replay"; readonly source: StageSnapshot };
1188
+ | {
1189
+ readonly kind: "execute";
1190
+ readonly source?: StageSnapshot;
1191
+ readonly parentIds: readonly string[];
1192
+ readonly answerReplay: PromptAnswerReplaySafety;
1193
+ }
1194
+ | {
1195
+ readonly kind: "replay";
1196
+ readonly source: StageSnapshot;
1197
+ readonly parentIds: readonly string[];
1198
+ readonly answerReplay: PromptAnswerReplaySafety;
1199
+ };
1200
+
1201
+ interface ContinuationReplayInput {
1202
+ readonly displayName: string;
1203
+ readonly replayKey: string;
1204
+ readonly parentIds: readonly string[];
1205
+ readonly stageId: string;
1206
+ readonly kind: "stage" | "prompt";
1207
+ }
1100
1208
 
1101
1209
  interface ContinuationReplayIndex {
1102
- decide(stageName: string, parentIds: readonly string[], stageId: string): ContinuationReplayDecision;
1210
+ decide(input: ContinuationReplayInput): ContinuationReplayDecision;
1211
+ markPromptAnswerReplayed(stageId: string): void;
1103
1212
  }
1104
1213
 
1105
1214
  function sameStringSet(left: readonly string[], right: readonly string[]): boolean {
@@ -1108,46 +1217,135 @@ function sameStringSet(left: readonly string[], right: readonly string[]): boole
1108
1217
  return left.every((value) => rightSet.has(value));
1109
1218
  }
1110
1219
 
1220
+ function sortedIdentity(values: readonly string[]): string {
1221
+ return [...values].sort().join("\u0000");
1222
+ }
1223
+
1111
1224
  function createContinuationReplayIndex(continuation: RunContinuationOpts | undefined): ContinuationReplayIndex {
1112
- if (continuation === undefined) return { decide: () => ({ kind: "execute" }) };
1225
+ if (continuation === undefined) {
1226
+ return {
1227
+ decide: (input) => ({
1228
+ kind: "execute",
1229
+ parentIds: input.parentIds,
1230
+ answerReplay: "unavailable",
1231
+ }),
1232
+ markPromptAnswerReplayed: () => {},
1233
+ };
1234
+ }
1113
1235
  const resumeStage = continuation.source.stages.find((stage) => stage.id === continuation.resumeFromStageId);
1114
1236
  if (resumeStage === undefined) {
1115
1237
  throw new Error(`pi-workflows: insufficient_state: resume stage ${continuation.resumeFromStageId} was not found in source run ${continuation.source.id}`);
1116
1238
  }
1117
1239
 
1118
- const stagesByName = new Map<string, StageSnapshot[]>();
1240
+ const stagesByReplayIdentity = new Map<string, StageSnapshot[]>();
1241
+ const promptDuplicateCounts = new Map<string, number>();
1119
1242
  for (const stage of continuation.source.stages) {
1120
- const stages = stagesByName.get(stage.name);
1243
+ const identity = stage.replayKey ?? stage.name;
1244
+ const stages = stagesByReplayIdentity.get(identity);
1121
1245
  if (stages === undefined) {
1122
- stagesByName.set(stage.name, [stage]);
1246
+ stagesByReplayIdentity.set(identity, [stage]);
1123
1247
  } else {
1124
1248
  stages.push(stage);
1125
1249
  }
1250
+ const duplicateKey = `${identity}\u0001${sortedIdentity(stage.parentIds)}`;
1251
+ promptDuplicateCounts.set(duplicateKey, (promptDuplicateCounts.get(duplicateKey) ?? 0) + 1);
1126
1252
  }
1127
1253
 
1128
1254
  const consumedSourceStageIds = new Set<string>();
1129
- const sourceStageIdByContinuationStageId = new Map<string, string>();
1255
+ const continuationStageIdBySourceStageId = new Map<string, string>();
1256
+ const replayablePromptContinuationStageIds = new Set<string>();
1257
+
1258
+ const failTopology = (displayName: string, replayKey: string, reason: "mismatch" | "ambiguous"): never => {
1259
+ throw new Error(`pi-workflows: insufficient_state: replay topology ${reason} for stage "${displayName}" (replayKey "${replayKey}") in source run ${continuation.source.id}`);
1260
+ };
1261
+
1262
+ const translateSourceParents = (source: StageSnapshot): string[] | undefined => {
1263
+ const parentIds: string[] = [];
1264
+ for (const sourceParentId of source.parentIds) {
1265
+ const continuationParentId = continuationStageIdBySourceStageId.get(sourceParentId);
1266
+ if (continuationParentId === undefined) return undefined;
1267
+ parentIds.push(continuationParentId);
1268
+ }
1269
+ return parentIds;
1270
+ };
1271
+
1272
+ const allSameParentSet = (candidates: readonly { readonly parentIds: readonly string[] }[]): boolean => {
1273
+ const first = candidates[0]?.parentIds;
1274
+ if (first === undefined) return false;
1275
+ return candidates.every((candidate) => sameStringSet(candidate.parentIds, first));
1276
+ };
1277
+
1278
+ const hasOnlyReplayablePromptParentDrift = (
1279
+ sourceParentIds: readonly string[],
1280
+ provisionalParentIds: readonly string[],
1281
+ ): boolean => {
1282
+ const sourceParentSet = new Set(sourceParentIds);
1283
+ const provisionalParentSet = new Set(provisionalParentIds);
1284
+ const driftParentIds = [
1285
+ ...sourceParentIds.filter((parentId) => !provisionalParentSet.has(parentId)),
1286
+ ...provisionalParentIds.filter((parentId) => !sourceParentSet.has(parentId)),
1287
+ ];
1288
+ return driftParentIds.length > 0 && driftParentIds.every((parentId) => replayablePromptContinuationStageIds.has(parentId));
1289
+ };
1290
+
1130
1291
  return {
1131
- decide(stageName: string, parentIds: readonly string[], stageId: string): ContinuationReplayDecision {
1132
- const candidates = stagesByName.get(stageName)?.filter((stage) => !consumedSourceStageIds.has(stage.id)) ?? [];
1133
- if (candidates.length === 0) return { kind: "execute" };
1134
-
1135
- const translatedParentIds = parentIds.map((parentId) => sourceStageIdByContinuationStageId.get(parentId));
1136
- const hasUnmappedParent = translatedParentIds.some((parentId) => parentId === undefined);
1137
- const matches = hasUnmappedParent
1138
- ? []
1139
- : candidates.filter((stage) => sameStringSet(translatedParentIds as string[], stage.parentIds));
1140
-
1141
- if (matches.length !== 1) {
1142
- const reason = matches.length === 0 ? "mismatch" : "ambiguous";
1143
- throw new Error(`pi-workflows: insufficient_state: replay topology ${reason} for stage "${stageName}" in source run ${continuation.source.id}`);
1292
+ markPromptAnswerReplayed(stageId: string): void {
1293
+ replayablePromptContinuationStageIds.add(stageId);
1294
+ },
1295
+
1296
+ decide(input: ContinuationReplayInput): ContinuationReplayDecision {
1297
+ const { displayName, replayKey, parentIds, stageId, kind } = input;
1298
+ let identity = replayKey;
1299
+ let candidates = stagesByReplayIdentity.get(replayKey)?.filter((stage) => !consumedSourceStageIds.has(stage.id)) ?? [];
1300
+ if (candidates.length === 0) {
1301
+ // Legacy snapshots created before replayKey existed can only be matched
1302
+ // by display name. Current stage and prompt nodes always carry replayKey.
1303
+ identity = displayName;
1304
+ candidates = stagesByReplayIdentity.get(displayName)?.filter((stage) => !consumedSourceStageIds.has(stage.id) && stage.replayKey === undefined) ?? [];
1144
1305
  }
1306
+ if (candidates.length === 0) {
1307
+ return { kind: "execute", parentIds, answerReplay: "unavailable" };
1308
+ }
1309
+
1310
+ const mappedCandidates = candidates
1311
+ .map((source) => ({ source, parentIds: translateSourceParents(source) }))
1312
+ .filter((candidate): candidate is { readonly source: StageSnapshot; readonly parentIds: string[] } => candidate.parentIds !== undefined);
1145
1313
 
1146
- const source = matches[0]!;
1147
- consumedSourceStageIds.add(source.id);
1148
- sourceStageIdByContinuationStageId.set(stageId, source.id);
1149
- if (source.status === "completed") return { kind: "replay", source };
1150
- return { kind: "execute", source };
1314
+ if (mappedCandidates.length === 0) {
1315
+ failTopology(displayName, replayKey, "mismatch");
1316
+ }
1317
+
1318
+ const provisionalMatches = mappedCandidates.filter((candidate) => sameStringSet(candidate.parentIds, parentIds));
1319
+ const hasPromptDriftMatch = kind === "prompt" &&
1320
+ allSameParentSet(mappedCandidates) &&
1321
+ hasOnlyReplayablePromptParentDrift(mappedCandidates[0]!.parentIds, parentIds);
1322
+ let matches: typeof mappedCandidates | undefined;
1323
+ if (provisionalMatches.length > 0) {
1324
+ matches = provisionalMatches;
1325
+ } else if (hasPromptDriftMatch) {
1326
+ matches = mappedCandidates;
1327
+ }
1328
+ if (matches === undefined) {
1329
+ return failTopology(displayName, replayKey, "mismatch");
1330
+ }
1331
+ if (matches.length > 1 && (kind !== "prompt" || !allSameParentSet(matches))) {
1332
+ failTopology(displayName, replayKey, "ambiguous");
1333
+ }
1334
+
1335
+ const selected = matches[0]!;
1336
+ const duplicateKey = `${identity}\u0001${sortedIdentity(selected.source.parentIds)}`;
1337
+ const ambiguousPromptAnswer = kind === "prompt" && (promptDuplicateCounts.get(duplicateKey) ?? 0) > 1;
1338
+ const answerReplay: PromptAnswerReplaySafety = ambiguousPromptAnswer
1339
+ ? "ambiguous"
1340
+ : selected.source.status === "completed"
1341
+ ? "allowed"
1342
+ : "unavailable";
1343
+ consumedSourceStageIds.add(selected.source.id);
1344
+ continuationStageIdBySourceStageId.set(selected.source.id, stageId);
1345
+ if (selected.source.status === "completed" && answerReplay === "allowed") {
1346
+ return { kind: "replay", source: selected.source, parentIds: selected.parentIds, answerReplay };
1347
+ }
1348
+ return { kind: "execute", source: selected.source, parentIds: selected.parentIds, answerReplay };
1151
1349
  },
1152
1350
  };
1153
1351
  }
@@ -1211,6 +1409,9 @@ export async function run<TInputs extends Record<string, unknown>>(
1211
1409
  ): Promise<RunResult> {
1212
1410
  const activeStore = opts.store ?? defaultStore;
1213
1411
  const adapters = opts.adapters ?? {};
1412
+ if (opts.usePromptNodesForUi === true && opts.ui !== undefined) {
1413
+ console.warn("pi-workflows: usePromptNodesForUi ignores the provided RunOpts.ui adapter");
1414
+ }
1214
1415
 
1215
1416
  // 0. maxDepth guard — reject before any store/persistence side effects.
1216
1417
  const depth = opts.depth ?? 0;
@@ -1309,6 +1510,12 @@ export async function run<TInputs extends Record<string, unknown>>(
1309
1510
  const stageById = (stageId: string): StageSnapshot | undefined =>
1310
1511
  runSnapshot.stages.find((stage) => stage.id === stageId);
1311
1512
 
1513
+ const setStageParentIds = (stage: StageSnapshot, parentIds: readonly string[]): void => {
1514
+ // Keep tracker and snapshot parent arrays in sync when topology is refreshed;
1515
+ // consumers should not cache the old parentIds reference across updates.
1516
+ stage.parentIds = Object.freeze([...parentIds]);
1517
+ };
1518
+
1312
1519
  const hasAncestor = (stage: StageSnapshot, ancestorId: string): boolean => {
1313
1520
  const queue = [...stage.parentIds];
1314
1521
  const seen = new Set<string>();
@@ -1432,26 +1639,217 @@ export async function run<TInputs extends Record<string, unknown>>(
1432
1639
  { once: true },
1433
1640
  );
1434
1641
 
1642
+ const buildPromptNodeUiAdapter = (): WorkflowUIAdapter => {
1643
+ const ask = async (descriptor: PromptDescriptor): Promise<unknown> => {
1644
+ if (ownController.signal.aborted) {
1645
+ return fallbackForPromptDescriptor(descriptor);
1646
+ }
1647
+
1648
+ const prompt = makePrompt(descriptor);
1649
+ const stageId = crypto.randomUUID();
1650
+ const provisionalParentIds = tracker.onSpawn(stageId, descriptor.kind);
1651
+ const replayKey = promptReplayKey(descriptor);
1652
+ const replayDecision = replayIndex.decide({
1653
+ displayName: descriptor.kind,
1654
+ replayKey,
1655
+ parentIds: provisionalParentIds,
1656
+ stageId,
1657
+ kind: "prompt",
1658
+ });
1659
+ const parentIds = replayDecision.parentIds;
1660
+ if (!sameStringSet(parentIds, provisionalParentIds)) {
1661
+ tracker.replaceParents(stageId, parentIds);
1662
+ }
1663
+ const replaySource = replayDecision.source;
1664
+ const replayAnswer = replayDecision.kind === "replay"
1665
+ // Replay decisions are only produced when continuation is present.
1666
+ ? activeStore.getStagePromptAnswer(opts.continuation!.source.id, replayDecision.source.id)
1667
+ : undefined;
1668
+ const shouldReplay = replayAnswer !== undefined;
1669
+ if (shouldReplay) {
1670
+ replayIndex.markPromptAnswerReplayed(stageId);
1671
+ }
1672
+ const replaySourceId = replaySource?.id;
1673
+ const promptAnswerStatus = getPromptAnswerState(shouldReplay, replaySourceId, replayDecision.answerReplay);
1674
+ const stageSnapshot: StageSnapshot = {
1675
+ id: stageId,
1676
+ name: descriptor.kind,
1677
+ replayKey,
1678
+ status: shouldReplay ? "completed" : "running",
1679
+ parentIds: Object.freeze(parentIds),
1680
+ startedAt: prompt.createdAt,
1681
+ promptFootprint: { ...prompt },
1682
+ toolEvents: [],
1683
+ attachable: !shouldReplay,
1684
+ ...(shouldReplay ? {
1685
+ endedAt: prompt.createdAt,
1686
+ durationMs: 0,
1687
+ promptAnswerState: promptAnswerStatus,
1688
+ replayedFromStageId: replaySourceId,
1689
+ replayed: true,
1690
+ } : replaySourceId !== undefined ? {
1691
+ promptAnswerState: promptAnswerStatus,
1692
+ replayedFromStageId: replaySourceId,
1693
+ replayed: false,
1694
+ } : {}),
1695
+ };
1696
+ let finalized = false;
1697
+ const finalizePromptStage = (status: "completed" | "failed" | "skipped"): void => {
1698
+ if (finalized) return;
1699
+ finalized = true;
1700
+ stageSnapshot.status = status;
1701
+ stageSnapshot.endedAt = Date.now();
1702
+ stageSnapshot.durationMs = elapsedStageMs(stageSnapshot, stageSnapshot.endedAt);
1703
+ activeStore.recordStageAttachable(runId, stageId, false);
1704
+ activeStore.recordStageEnd(runId, stageSnapshot);
1705
+ opts.onStageEnd?.(runId, stageSnapshot);
1706
+ if (opts.persistence) {
1707
+ appendStageEnd(opts.persistence, {
1708
+ runId,
1709
+ stageId,
1710
+ status: stageSnapshot.status,
1711
+ durationMs: stageSnapshot.durationMs,
1712
+ ...(stageSnapshot.error !== undefined ? { error: stageSnapshot.error } : {}),
1713
+ ...(stageSnapshot.failureKind !== undefined ? { failureKind: stageSnapshot.failureKind } : {}),
1714
+ ...(stageSnapshot.failureMessage !== undefined ? { failureMessage: stageSnapshot.failureMessage } : {}),
1715
+ ...(stageSnapshot.skippedReason !== undefined ? { skippedReason: stageSnapshot.skippedReason } : {}),
1716
+ ...stageReplayFields(stageSnapshot),
1717
+ });
1718
+ }
1719
+ tracker.onSettle(stageId);
1720
+ };
1721
+
1722
+ activeStore.recordStageStart(runId, stageSnapshot);
1723
+ opts.onStageStart?.(runId, stageSnapshot);
1724
+ if (opts.persistence) {
1725
+ appendStageStart(opts.persistence, {
1726
+ runId,
1727
+ stageId,
1728
+ name: stageSnapshot.name,
1729
+ parentIds: stageSnapshot.parentIds,
1730
+ ...stageReplayFields(stageSnapshot),
1731
+ ts: prompt.createdAt,
1732
+ });
1733
+ }
1734
+ if (shouldReplay) {
1735
+ await Promise.resolve();
1736
+ finalizePromptStage("completed");
1737
+ return replayAnswer.value;
1738
+ }
1739
+ const accepted = activeStore.recordStagePendingPrompt(runId, stageId, prompt);
1740
+ if (!accepted) {
1741
+ stageSnapshot.skippedReason = "prompt-unavailable";
1742
+ finalizePromptStage("skipped");
1743
+ return fallbackForPromptDescriptor(descriptor);
1744
+ }
1745
+
1746
+ const waiter = activeStore.awaitStagePendingPrompt(runId, stageId, prompt.id);
1747
+ try {
1748
+ const response = await new Promise<unknown>((resolve, reject) => {
1749
+ const onAbort = (): void => {
1750
+ activeStore.resolveStagePendingPrompt(
1751
+ runId,
1752
+ stageId,
1753
+ prompt.id,
1754
+ fallbackForPromptDescriptor(descriptor),
1755
+ { recordAnswer: false },
1756
+ );
1757
+ reject(hilAbortError(ownController.signal));
1758
+ };
1759
+ if (ownController.signal.aborted) {
1760
+ onAbort();
1761
+ return;
1762
+ }
1763
+ ownController.signal.addEventListener("abort", onAbort, { once: true });
1764
+ waiter.then(
1765
+ (value) => {
1766
+ ownController.signal.removeEventListener("abort", onAbort);
1767
+ resolve(value);
1768
+ },
1769
+ (err: unknown) => {
1770
+ ownController.signal.removeEventListener("abort", onAbort);
1771
+ reject(err);
1772
+ },
1773
+ );
1774
+ });
1775
+ finalizePromptStage("completed");
1776
+ return response;
1777
+ } catch (err) {
1778
+ if (ownController.signal.aborted) {
1779
+ stageSnapshot.skippedReason = "run-aborted";
1780
+ finalizePromptStage("skipped");
1781
+ } else {
1782
+ applyFailureToStage(stageSnapshot, classifyWorkflowFailure(err));
1783
+ finalizePromptStage("failed");
1784
+ }
1785
+ throw err;
1786
+ }
1787
+ };
1788
+
1789
+ return {
1790
+ async input(promptText: string): Promise<string> {
1791
+ const response = await ask({ kind: "input", message: promptText });
1792
+ return typeof response === "string" ? response : String(response ?? "");
1793
+ },
1794
+ async confirm(message: string): Promise<boolean> {
1795
+ const response = await ask({ kind: "confirm", message });
1796
+ return response === true;
1797
+ },
1798
+ async select<T extends string>(message: string, options: readonly T[]): Promise<T> {
1799
+ if (options.length === 0) {
1800
+ throw new Error("pi-workflows: ctx.ui.select requires at least one option");
1801
+ }
1802
+ const response = await ask({ kind: "select", message, choices: options });
1803
+ if (typeof response === "string" && (options as readonly string[]).includes(response)) {
1804
+ return response as T;
1805
+ }
1806
+ return options[0]!;
1807
+ },
1808
+ async editor(initial?: string): Promise<string> {
1809
+ const response = await ask({
1810
+ kind: "editor",
1811
+ message: "Edit and save to continue.",
1812
+ initial,
1813
+ });
1814
+ return typeof response === "string" ? response : initial ?? "";
1815
+ },
1816
+ };
1817
+ };
1818
+
1435
1819
  // 5. Build WorkflowRunContext
1436
1820
  const ctx: WorkflowRunContext<TInputs> = {
1437
1821
  inputs: resolvedInputs as TInputs,
1438
- ui: opts.ui ?? makeUnavailableUIContext(),
1822
+ // Prompt nodes and caller-provided UI adapters are mutually exclusive;
1823
+ // executor-owned prompt nodes intentionally take precedence when enabled.
1824
+ ui: opts.usePromptNodesForUi === true ? buildPromptNodeUiAdapter() : opts.ui ?? makeUnavailableUIContext(),
1439
1825
 
1440
1826
  stage(name: string, options?: StageOptions, stageFailFastScope?: ParallelFailFastScope) {
1441
1827
  // a. Generate stageId
1442
1828
  const stageId = crypto.randomUUID();
1443
1829
 
1444
- // b. tracker.onSpawn → parentIds
1445
- const parentIds = tracker.onSpawn(stageId, name);
1830
+ // b. tracker.onSpawn → provisional parentIds
1831
+ const provisionalParentIds = tracker.onSpawn(stageId, name);
1446
1832
 
1447
1833
  // c. Create StageSnapshot as "pending"
1448
- const replayDecision = replayIndex.decide(name, parentIds, stageId);
1834
+ const replayKey = `stage:${name}`;
1835
+ const replayDecision = replayIndex.decide({
1836
+ displayName: name,
1837
+ replayKey,
1838
+ parentIds: provisionalParentIds,
1839
+ stageId,
1840
+ kind: "stage",
1841
+ });
1842
+ const parentIds = replayDecision.parentIds;
1843
+ if (!sameStringSet(parentIds, provisionalParentIds)) {
1844
+ tracker.replaceParents(stageId, parentIds);
1845
+ }
1449
1846
  const replaySource = replayDecision.kind === "replay" ? replayDecision.source : undefined;
1450
1847
  const shouldReplay = replaySource !== undefined;
1451
1848
 
1452
1849
  const stageSnapshot: StageSnapshot = {
1453
1850
  id: stageId,
1454
1851
  name,
1852
+ replayKey,
1455
1853
  status: shouldReplay ? "completed" : "pending",
1456
1854
  parentIds: Object.freeze(parentIds),
1457
1855
  toolEvents: [],
@@ -1482,8 +1880,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1482
1880
  stageId,
1483
1881
  name,
1484
1882
  parentIds: stageSnapshot.parentIds,
1485
- ...(stageSnapshot.replayedFromStageId !== undefined ? { replayedFromStageId: stageSnapshot.replayedFromStageId } : {}),
1486
- ...(stageSnapshot.replayed !== undefined ? { replayed: stageSnapshot.replayed } : {}),
1883
+ ...stageReplayFields(stageSnapshot),
1487
1884
  ts: stageSnapshot.startedAt ?? Date.now(),
1488
1885
  });
1489
1886
  };
@@ -1505,8 +1902,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1505
1902
  status: "completed",
1506
1903
  durationMs: 0,
1507
1904
  ...(stageSnapshot.result !== undefined ? { summary: stageSnapshot.result } : {}),
1508
- replayedFromStageId: replaySource.id,
1509
- replayed: true,
1905
+ ...stageReplayFields(stageSnapshot),
1510
1906
  });
1511
1907
  }
1512
1908
  tracker.onSettle(stageId);
@@ -1520,7 +1916,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1520
1916
  const rejectReplayMutation = (action: string): never => {
1521
1917
  throw new Error(`pi-workflows: replayed stage "${name}" cannot ${action}`);
1522
1918
  };
1523
- const replayContext: StageContext & Pick<InternalStageContext, "__modelFallbackMeta"> = {
1919
+ const replayContext: InternalStageContext = {
1524
1920
  name,
1525
1921
  prompt: replayText,
1526
1922
  complete: replayText,
@@ -1542,11 +1938,24 @@ export async function run<TInputs extends Record<string, unknown>>(
1542
1938
  compact: async () => rejectReplayMutation("compact"),
1543
1939
  abortCompaction: () => rejectReplayMutation("abort compaction"),
1544
1940
  abort: async () => rejectReplayMutation("abort"),
1941
+ __dispose: async () => {},
1942
+ __getLastAssistantText: () => replayResult,
1943
+ getLastAssistantText: () => replayResult,
1944
+ __ensureSession: async () => {},
1945
+ __sessionMeta: () => ({
1946
+ sessionId: replaySource.sessionId,
1947
+ sessionFile: replaySource.sessionFile,
1948
+ }),
1949
+ __agentSession: () => undefined,
1950
+ __pendingMessageCount: () => 0,
1545
1951
  __modelFallbackMeta: () => ({
1546
1952
  ...(replaySource.model !== undefined ? { model: replaySource.model } : {}),
1547
1953
  ...(replaySource.attemptedModels !== undefined ? { attemptedModels: replaySource.attemptedModels } : {}),
1548
1954
  ...(replaySource.modelAttempts !== undefined ? { modelAttempts: replaySource.modelAttempts } : {}),
1549
1955
  }),
1956
+ __requestPause: async () => rejectReplayMutation("pause"),
1957
+ __resume: async () => rejectReplayMutation("resume"),
1958
+ __isPaused: () => false,
1550
1959
  };
1551
1960
  return replayContext;
1552
1961
  }
@@ -1564,25 +1973,35 @@ export async function run<TInputs extends Record<string, unknown>>(
1564
1973
  models: opts.models,
1565
1974
  });
1566
1975
  const activeAskUserQuestionCalls = new Set<string>();
1976
+ let activeAskUserQuestionAnonymousCalls = 0;
1977
+ const hasActiveAskUserQuestion = (): boolean =>
1978
+ activeAskUserQuestionCalls.size > 0 || activeAskUserQuestionAnonymousCalls > 0;
1567
1979
  const unsubscribeAskUserQuestionWatcher = innerCtx.subscribe((event) => {
1568
1980
  const toolEvent = askUserQuestionToolEvent(event);
1569
1981
  if (!toolEvent) return;
1570
1982
  if (toolEvent.phase === "start") {
1571
- activeAskUserQuestionCalls.add(toolEvent.callId);
1983
+ if (toolEvent.callId !== undefined) activeAskUserQuestionCalls.add(toolEvent.callId);
1984
+ else activeAskUserQuestionAnonymousCalls += 1;
1572
1985
  activeStore.recordStageAwaitingInput(runId, stageId, true);
1573
1986
  return;
1574
1987
  }
1575
1988
 
1576
- if (toolEvent.nameMatched || activeAskUserQuestionCalls.has(toolEvent.callId)) {
1989
+ if (toolEvent.callId !== undefined && activeAskUserQuestionCalls.has(toolEvent.callId)) {
1577
1990
  activeAskUserQuestionCalls.delete(toolEvent.callId);
1578
- if (activeAskUserQuestionCalls.size === 0) {
1579
- activeStore.recordStageAwaitingInput(runId, stageId, false);
1580
- }
1991
+ } else if (toolEvent.callId === undefined && toolEvent.nameMatched) {
1992
+ activeAskUserQuestionAnonymousCalls = Math.max(0, activeAskUserQuestionAnonymousCalls - 1);
1993
+ } else {
1994
+ return;
1995
+ }
1996
+
1997
+ if (!hasActiveAskUserQuestion()) {
1998
+ activeStore.recordStageAwaitingInput(runId, stageId, false);
1581
1999
  }
1582
2000
  });
1583
2001
  const disposeInnerContext = async (): Promise<void> => {
1584
2002
  unsubscribeAskUserQuestionWatcher();
1585
2003
  activeAskUserQuestionCalls.clear();
2004
+ activeAskUserQuestionAnonymousCalls = 0;
1586
2005
  activeStore.recordStageAwaitingInput(runId, stageId, false);
1587
2006
  await innerCtx.__dispose();
1588
2007
  };
@@ -1596,46 +2015,13 @@ export async function run<TInputs extends Record<string, unknown>>(
1596
2015
  unregisterStageHandle();
1597
2016
  await disposeInnerContext();
1598
2017
  };
1599
- const hasQueuedLiveWork = (): boolean =>
1600
- innerCtx.isStreaming || innerCtx.__pendingMessageCount() > 0 || activeAskUserQuestionCalls.size > 0;
1601
- const releaseLiveHandleWhenIdle = async (): Promise<void> => {
2018
+ const dropStageControlForCompletion = async (): Promise<void> => {
2019
+ // Completion removes the stage from workflow-level pause/resume and
2020
+ // dependency cascades, but must not turn the attached/reopenable chat
2021
+ // into a read-only archive. Keep the direct live handle registered for
2022
+ // post-completion follow-ups until the registry/store is explicitly
2023
+ // cleared by the host.
1602
2024
  dropStageControlHandle();
1603
- if (!hasQueuedLiveWork()) {
1604
- await releaseLiveHandle();
1605
- return;
1606
- }
1607
-
1608
- // The queued-work branch installs asynchronous cleanup and returns once
1609
- // the release watcher is armed. Inner-context events normally trigger
1610
- // the subscription when streaming/pending-message counters change, but
1611
- // SDK prompt/tool cleanup can also drain after the stage has stopped
1612
- // emitting workflow-visible events. The unref'd 250 ms interval is a
1613
- // fallback for that silent drain path and is cleared as soon as the
1614
- // handle becomes idle.
1615
- let unsubscribe = (): void => {};
1616
- let pollTimer: ReturnType<typeof setInterval> | undefined;
1617
- const cleanupWatcher = (): void => {
1618
- unsubscribe();
1619
- if (pollTimer !== undefined) {
1620
- clearInterval(pollTimer);
1621
- pollTimer = undefined;
1622
- }
1623
- };
1624
- const releaseIfIdle = (): void => {
1625
- if (liveHandleReleased) {
1626
- cleanupWatcher();
1627
- return;
1628
- }
1629
- if (hasQueuedLiveWork()) return;
1630
- cleanupWatcher();
1631
- void releaseLiveHandle().catch((error: unknown) => {
1632
- console.debug("pi-workflows: failed to release idle stage handle", error);
1633
- });
1634
- };
1635
- unsubscribe = innerCtx.subscribe(() => queueMicrotask(releaseIfIdle));
1636
- pollTimer = setInterval(releaseIfIdle, 250);
1637
- pollTimer.unref?.();
1638
- releaseIfIdle();
1639
2025
  };
1640
2026
 
1641
2027
  // e. Register a live stage-control handle so attached panes can
@@ -1711,6 +2097,9 @@ export async function run<TInputs extends Record<string, unknown>>(
1711
2097
  subscribe(listener: AgentSessionEventListener) {
1712
2098
  return innerCtx.subscribe(listener);
1713
2099
  },
2100
+ async dispose() {
2101
+ await releaseLiveHandle();
2102
+ },
1714
2103
  };
1715
2104
  let stageFinalized = false;
1716
2105
  const finalizeStageSnapshot = (): boolean => {
@@ -1739,8 +2128,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1739
2128
  ...(stageSnapshot.failureMessage !== undefined ? { failureMessage: stageSnapshot.failureMessage } : {}),
1740
2129
  ...(stageSnapshot.skippedReason !== undefined ? { skippedReason: stageSnapshot.skippedReason } : {}),
1741
2130
  ...(stageSnapshot.result !== undefined && stageSnapshot.status === "completed" ? { summary: stageSnapshot.result } : {}),
1742
- ...(stageSnapshot.replayedFromStageId !== undefined ? { replayedFromStageId: stageSnapshot.replayedFromStageId } : {}),
1743
- ...(stageSnapshot.replayed !== undefined ? { replayed: stageSnapshot.replayed } : {}),
2131
+ ...stageReplayFields(stageSnapshot),
1744
2132
  });
1745
2133
  }
1746
2134
 
@@ -1761,7 +2149,7 @@ export async function run<TInputs extends Record<string, unknown>>(
1761
2149
  markSkippedForParallelFailFast();
1762
2150
  finalizeStageSnapshot();
1763
2151
  void innerCtx.abort().catch(() => {});
1764
- void releaseLiveHandleWhenIdle().catch(() => {});
2152
+ void dropStageControlForCompletion().catch(() => {});
1765
2153
  };
1766
2154
  stageFailFastScope?.activeStages.set(stageId, { skip: skipForParallelFailFast });
1767
2155
 
@@ -1815,6 +2203,13 @@ export async function run<TInputs extends Record<string, unknown>>(
1815
2203
  throw err;
1816
2204
  }
1817
2205
 
2206
+ if (opts.continuation === undefined && stageSnapshot.startedAt === undefined) {
2207
+ const actualParentIds = tracker.currentParents();
2208
+ if (!sameStringSet(actualParentIds, stageSnapshot.parentIds)) {
2209
+ tracker.replaceParents(stageId, actualParentIds);
2210
+ setStageParentIds(stageSnapshot, actualParentIds);
2211
+ }
2212
+ }
1818
2213
  stageSnapshot.status = "running";
1819
2214
  stageSnapshot.startedAt = Date.now();
1820
2215
  activeStore.recordStageStart(runId, stageSnapshot);
@@ -1880,12 +2275,10 @@ export async function run<TInputs extends Record<string, unknown>>(
1880
2275
 
1881
2276
  finalizeStageSnapshot();
1882
2277
  // The stage has finished participating in workflow scheduling. Drop it
1883
- // from run-level pause/resume and cascade-pause lookups immediately.
1884
- // If no SDK queue/active input remains, release the live chat handle so
1885
- // the node reopens as a read-only archived session. Queued messages keep
1886
- // the direct handle alive only until the SDK reports that the queue has
1887
- // drained.
1888
- await releaseLiveHandleWhenIdle().catch(() => {});
2278
+ // from run-level pause/resume and cascade-pause lookups immediately,
2279
+ // while retaining the direct chat handle so completed nodes can be
2280
+ // reopened and continued instead of becoming read-only archives.
2281
+ await dropStageControlForCompletion().catch(() => {});
1889
2282
  limiter.release();
1890
2283
  }
1891
2284
  };