@bastani/atomic 0.8.14 → 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 +23 -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
@@ -84,6 +84,8 @@ export interface StageControlHandle {
84
84
  * before the session exists are buffered and bound on first attach.
85
85
  */
86
86
  subscribe(listener: AgentSessionEventListener): () => void;
87
+ /** Release the underlying SDK session and unregister this direct chat handle. */
88
+ dispose?(): void | Promise<void>;
87
89
  }
88
90
 
89
91
  /**
@@ -131,8 +133,9 @@ export interface StageControlRegistry {
131
133
  /** Build a run-level control aggregate. Cheap; not memoised. */
132
134
  run(runId: string): WorkflowRunControlHandle;
133
135
  /**
134
- * Drop every registration. Used on session boundaries to release
135
- * any leaked handles when the host store is cleared.
136
+ * Drop every registration and invoke each handle's optional dispose hook.
137
+ * Used on session boundaries to release retained direct chat handles and
138
+ * their subscriptions when the host store is cleared.
136
139
  */
137
140
  clear(): void;
138
141
  }
@@ -267,7 +270,15 @@ export function createStageControlRegistry(): StageControlRegistry {
267
270
  return makeRunHandle(runId);
268
271
  },
269
272
  clear(): void {
273
+ const handles = [..._byRun.values()].flatMap((runMap) =>
274
+ [...runMap.values()].map((entry) => entry.handle),
275
+ );
270
276
  _byRun.clear();
277
+ for (const handle of handles) {
278
+ void Promise.resolve(handle.dispose?.()).catch((err: unknown) => {
279
+ console.warn("pi-workflows: stage handle dispose failed", err);
280
+ });
281
+ }
271
282
  },
272
283
  };
273
284
  }
@@ -157,7 +157,14 @@ function stripWorkflowOnlyOptions(options: StageOptions | undefined): CreateAgen
157
157
  return sessionOptions as CreateAgentSessionOptions;
158
158
  }
159
159
 
160
- function missingAdapter(): never {
160
+ type AgentSessionConsumer = "prompt" | "complete";
161
+
162
+ function missingAdapter(consumer: AgentSessionConsumer): never {
163
+ if (consumer === "complete") {
164
+ throw new Error(
165
+ "pi-workflows: ctx.complete requires either RunOpts.adapters.complete or RunOpts.adapters.agentSession",
166
+ );
167
+ }
161
168
  throw new Error(
162
169
  "pi-workflows: prompt adapter not configured — provide an AgentSessionAdapter via RunOpts.adapters.agentSession",
163
170
  );
@@ -457,27 +464,30 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
457
464
  return created;
458
465
  }
459
466
 
460
- async function createSession(candidate: WorkflowResolvedModelCandidate | undefined): Promise<StageSessionRuntime> {
467
+ async function createSession(
468
+ candidate: WorkflowResolvedModelCandidate | undefined,
469
+ consumer: AgentSessionConsumer,
470
+ ): Promise<StageSessionRuntime> {
461
471
  const created = adapters.agentSession
462
472
  ? await adapters.agentSession.create(stripWorkflowOnlyOptions(stageOptionsForCandidate(candidate)) as StageSessionCreateOptions, {
463
473
  ...meta,
464
474
  stageOptions: stageOptionsForCandidate(candidate),
465
475
  })
466
- : missingAdapter();
476
+ : missingAdapter(consumer);
467
477
  return attachSession(created);
468
478
  }
469
479
 
470
- async function ensureSession(): Promise<StageSessionRuntime> {
480
+ async function ensureSession(consumer: AgentSessionConsumer = "prompt"): Promise<StageSessionRuntime> {
471
481
  if (disposed) throw new Error(`pi-workflows: stage "${stageName}" session has been disposed`);
472
482
  if (!sessionPromise) {
473
483
  sessionPromise = (async () => {
474
- if (!hasExplicitModelFallbackConfig) return createSession(undefined);
484
+ if (!hasExplicitModelFallbackConfig) return createSession(undefined, consumer);
475
485
  const candidates = await modelCandidates();
476
486
  const first = candidates[0];
477
- if (first === undefined) return createSession(undefined);
487
+ if (first === undefined) return createSession(undefined, consumer);
478
488
  activeCandidateIndex = 0;
479
489
  selectedModel = first.id;
480
- return createSession(first);
490
+ return createSession(first, consumer);
481
491
  })();
482
492
  }
483
493
  return sessionPromise;
@@ -531,15 +541,19 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
531
541
  }
532
542
  }
533
543
 
534
- async function promptWithFallback(text: string, sdkOptions: PromptOptions | undefined): Promise<void> {
544
+ async function promptWithFallback(
545
+ text: string,
546
+ sdkOptions: PromptOptions | undefined,
547
+ consumer: AgentSessionConsumer = "prompt",
548
+ ): Promise<void> {
535
549
  if (!hasExplicitModelFallbackConfig) {
536
- await promptWithPauseResume(await ensureSession(), text, sdkOptions);
550
+ await promptWithPauseResume(await ensureSession(consumer), text, sdkOptions);
537
551
  return;
538
552
  }
539
553
 
540
554
  const candidates = await modelCandidates();
541
555
  if (candidates.length === 0) {
542
- await promptWithPauseResume(await ensureSession(), text, sdkOptions);
556
+ await promptWithPauseResume(await ensureSession(consumer), text, sdkOptions);
543
557
  return;
544
558
  }
545
559
 
@@ -548,7 +562,7 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
548
562
  const candidate = candidates[index]!;
549
563
  const activeSession = session && activeCandidateIndex === index
550
564
  ? session
551
- : await createSession(candidate);
565
+ : await createSession(candidate, consumer);
552
566
  activeCandidateIndex = index;
553
567
  selectedModel = candidate.id;
554
568
  try {
@@ -594,13 +608,25 @@ export function createStageContext(opts: StageRunnerOpts): InternalStageContext
594
608
  },
595
609
 
596
610
  async complete(text, completeOpts) {
597
- if (!adapters.complete) {
611
+ if (adapters.complete) {
612
+ lastAssistantText = await adapters.complete.complete(text, completeOpts, meta);
613
+ adapterMessages = assistantMessage(lastAssistantText);
614
+ return lastAssistantText;
615
+ }
616
+ if (
617
+ completeOpts?.model !== undefined ||
618
+ completeOpts?.maxTokens !== undefined ||
619
+ completeOpts?.fallbackModels !== undefined
620
+ ) {
598
621
  throw new Error(
599
- "pi-workflows: complete adapter not configured — provide a CompleteAdapter via RunOpts.adapters.complete",
622
+ "pi-workflows: complete options require a CompleteAdapter via RunOpts.adapters.complete",
600
623
  );
601
624
  }
602
- lastAssistantText = await adapters.complete.complete(text, completeOpts, meta);
603
- adapterMessages = assistantMessage(lastAssistantText);
625
+ // Intentional fallback: when a CompleteAdapter is not configured,
626
+ // `ctx.complete()` can still use the stage AgentSession for simple text
627
+ // completions. Completion-specific options require the dedicated adapter.
628
+ await promptWithFallback(text, undefined, "complete");
629
+ lastAssistantText = lastAssistantTextFromSession(session, lastAssistantText) ?? "";
604
630
  return lastAssistantText;
605
631
  },
606
632
 
@@ -36,6 +36,37 @@ export class GraphFrontierTracker {
36
36
  return parents;
37
37
  }
38
38
 
39
+ /**
40
+ * Snapshot the current frontier without registering or mutating a stage.
41
+ *
42
+ * Use this when an already-spawned stage needs its parents refreshed before
43
+ * it starts; `onSpawn` must only be called for the initial `ctx.stage()`
44
+ * invocation that creates the graph node.
45
+ */
46
+ currentParents(): string[] {
47
+ return Array.from(this.frontier);
48
+ }
49
+
50
+ /**
51
+ * Replace the recorded parents for a stage before it settles.
52
+ *
53
+ * Continuation replay uses source-run topology as authoritative: a replayed
54
+ * stage may spawn with provisional parents inferred from the continuation's
55
+ * current frontier, then install the translated source parents before the
56
+ * stage is recorded or settled.
57
+ */
58
+ replaceParents(stageId: string, parentIds: readonly string[]): void {
59
+ const parents = Array.from(parentIds);
60
+ this.stageParents.set(stageId, parents);
61
+ const node = this.nodes.get(stageId);
62
+ if (node !== undefined) {
63
+ this.nodes.set(stageId, {
64
+ ...node,
65
+ parentIds: Object.freeze(parents),
66
+ });
67
+ }
68
+ }
69
+
39
70
  /**
40
71
  * Call when the stage's Promise settles.
41
72
  * Removes the stage's parents from the frontier and adds stageId to frontier.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Prompt replay keys include a normalized workflow-author callsite so two
3
+ * `ctx.ui.*` prompts with identical descriptors can still replay the matching
4
+ * answer after continuation. Runtime/framework frames are filtered out; the
5
+ * selected frame is hashed by the executor and never persisted in raw form.
6
+ */
7
+
8
+ import { relative } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ export interface PromptCallsiteFrame {
12
+ readonly normalizedPath: string;
13
+ readonly lineNumber: string;
14
+ readonly columnNumber: string;
15
+ }
16
+
17
+ const PACKAGED_WORKFLOW_RUNTIME_ROOTS = [
18
+ "/dist/builtin/workflows/src/",
19
+ "/node_modules/@bastani/workflows/src/",
20
+ ] as const;
21
+
22
+ function normalizeSlashes(path: string): string {
23
+ return path.replace(/\\/g, "/");
24
+ }
25
+
26
+ export function normalizeStackPath(framePath: string): string {
27
+ let normalized = framePath;
28
+ if (normalized.startsWith("file://")) {
29
+ try {
30
+ normalized = fileURLToPath(normalized);
31
+ } catch {
32
+ try {
33
+ normalized = decodeURIComponent(new URL(normalized).pathname);
34
+ } catch {
35
+ // Keep the original string; it will only be hashed, not persisted raw.
36
+ }
37
+ }
38
+ }
39
+
40
+ normalized = normalizeSlashes(normalized).replace(/^\/([A-Za-z]:\/)/, "$1");
41
+
42
+ const cwd = normalizeSlashes(process.cwd());
43
+ if (normalized.startsWith(`${cwd}/`)) {
44
+ normalized = normalizeSlashes(relative(process.cwd(), normalized));
45
+ }
46
+
47
+ return normalized;
48
+ }
49
+
50
+ function currentModuleRuntimeRoot(): string | undefined {
51
+ const modulePath = normalizeStackPath(fileURLToPath(import.meta.url));
52
+ const markers = ["packages/workflows/src/", ...PACKAGED_WORKFLOW_RUNTIME_ROOTS.map((root) => root.slice(1))];
53
+ for (const marker of markers) {
54
+ const index = modulePath.indexOf(marker);
55
+ if (index >= 0) return modulePath.slice(0, index + marker.length);
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ const CURRENT_WORKFLOW_RUNTIME_ROOT = currentModuleRuntimeRoot();
61
+
62
+ export function isWorkflowRuntimeFrame(normalizedPath: string): boolean {
63
+ const path = normalizeSlashes(normalizedPath);
64
+ if (CURRENT_WORKFLOW_RUNTIME_ROOT !== undefined && path.startsWith(CURRENT_WORKFLOW_RUNTIME_ROOT)) {
65
+ return true;
66
+ }
67
+ const comparable = path.startsWith("/") ? path : `/${path}`;
68
+ return PACKAGED_WORKFLOW_RUNTIME_ROOTS.some((runtimeRoot) => comparable.includes(runtimeRoot));
69
+ }
70
+
71
+ export function parsePromptStackFrame(stackLine: string): PromptCallsiteFrame | undefined {
72
+ const line = stackLine.trim();
73
+ if (line.length === 0) return undefined;
74
+ if (line.includes("node:internal") || line.includes("bun:") || line.includes("(native:")) return undefined;
75
+
76
+ const match = line.match(/(\S+):(\d+):(\d+)\)?$/);
77
+ if (!match) return undefined;
78
+ const [, rawFramePath, lineNumber, columnNumber] = match;
79
+ if (rawFramePath === undefined || lineNumber === undefined || columnNumber === undefined) return undefined;
80
+
81
+ const normalizedPath = normalizeStackPath(rawFramePath.replace(/^\(/, ""));
82
+ return { normalizedPath, lineNumber, columnNumber };
83
+ }
84
+
85
+ export function normalizedPromptCallsiteFrame(stackLine: string): string | undefined {
86
+ const frame = parsePromptStackFrame(stackLine);
87
+ if (frame === undefined) return undefined;
88
+ if (isWorkflowRuntimeFrame(frame.normalizedPath)) return undefined;
89
+ return `${frame.normalizedPath}:${frame.lineNumber}:${frame.columnNumber}`;
90
+ }
91
+
92
+ export function selectPromptCallsiteFrame(stack: string): string | undefined {
93
+ return stack
94
+ .split("\n")
95
+ .slice(1)
96
+ .map(normalizedPromptCallsiteFrame)
97
+ .find((candidate): candidate is string => candidate !== undefined);
98
+ }
@@ -278,10 +278,12 @@ function _buildStageSnapshots(
278
278
  return [...stageMap.values()];
279
279
  }
280
280
 
281
- function replayMetadata(payload: Record<string, unknown>): Pick<StageSnapshot, "replayedFromStageId" | "replayed"> {
281
+ function replayMetadata(payload: Record<string, unknown>): Pick<StageSnapshot, "replayKey" | "replayedFromStageId" | "replayed"> {
282
+ const replayKey = payload["replayKey"];
282
283
  const replayedFromStageId = payload["replayedFromStageId"];
283
284
  const replayed = payload["replayed"];
284
285
  return {
286
+ ...(typeof replayKey === "string" ? { replayKey } : {}),
285
287
  ...(typeof replayedFromStageId === "string" ? { replayedFromStageId } : {}),
286
288
  ...(typeof replayed === "boolean" ? { replayed } : {}),
287
289
  };
@@ -42,6 +42,7 @@ export interface StageStartPayload {
42
42
  readonly name: string;
43
43
  readonly parentIds: readonly string[];
44
44
  readonly model?: string;
45
+ readonly replayKey?: string;
45
46
  readonly replayedFromStageId?: string;
46
47
  readonly replayed?: boolean;
47
48
  readonly ts: number;
@@ -64,6 +65,7 @@ export interface StageEndPayload {
64
65
  readonly failureKind?: string;
65
66
  readonly failureMessage?: string;
66
67
  readonly skippedReason?: string;
68
+ readonly replayKey?: string;
67
69
  readonly replayedFromStageId?: string;
68
70
  readonly replayed?: boolean;
69
71
  }
@@ -113,6 +115,7 @@ export function appendStageStart(api: PersistenceAPI, payload: StageStartPayload
113
115
  name: payload.name,
114
116
  parentIds: [...payload.parentIds],
115
117
  ...(payload.model !== undefined ? { model: payload.model } : {}),
118
+ ...(payload.replayKey !== undefined ? { replayKey: payload.replayKey } : {}),
116
119
  ...(payload.replayedFromStageId !== undefined ? { replayedFromStageId: payload.replayedFromStageId } : {}),
117
120
  ...(payload.replayed !== undefined ? { replayed: payload.replayed } : {}),
118
121
  ts: payload.ts,
@@ -147,6 +150,7 @@ export function appendStageEnd(
147
150
  ...(payload.failureKind !== undefined ? { failureKind: payload.failureKind } : {}),
148
151
  ...(payload.failureMessage !== undefined ? { failureMessage: payload.failureMessage } : {}),
149
152
  ...(payload.skippedReason !== undefined ? { skippedReason: payload.skippedReason } : {}),
153
+ ...(payload.replayKey !== undefined ? { replayKey: payload.replayKey } : {}),
150
154
  ...(payload.replayedFromStageId !== undefined ? { replayedFromStageId: payload.replayedFromStageId } : {}),
151
155
  ...(payload.replayed !== undefined ? { replayed: payload.replayed } : {}),
152
156
  });
@@ -63,7 +63,12 @@ export interface StageSnapshot {
63
63
  readonly id: string;
64
64
  readonly name: string;
65
65
  status: StageStatus;
66
- readonly parentIds: readonly string[];
66
+ /**
67
+ * Parent stage ids. Treat as immutable from consumer code; the executor may
68
+ * replace the frozen array before a stage starts when late topology inference
69
+ * refreshes parents, so do not cache this reference across store updates.
70
+ */
71
+ parentIds: readonly string[];
67
72
  startedAt?: number;
68
73
  endedAt?: number;
69
74
  durationMs?: number;
@@ -75,6 +80,12 @@ export interface StageSnapshot {
75
80
  failureMessage?: string;
76
81
  /** Reason for stages skipped by fail-fast/cascade handling. */
77
82
  skippedReason?: string;
83
+ /** Stable continuation replay identity, separate from display name. */
84
+ replayKey?: string;
85
+ /** Snapshot-safe prompt answer availability marker; never contains the raw answer. */
86
+ promptAnswerState?: "available" | "unavailable" | "ambiguous";
87
+ /** Snapshot-safe descriptor of the prompt UI shown by this stage; never contains the raw answer. */
88
+ promptFootprint?: PendingPrompt;
78
89
  /** Source stage id when this stage was replayed during failed-run continuation. */
79
90
  replayedFromStageId?: string;
80
91
  /** True when provider work was skipped by continuation replay. */
@@ -5,6 +5,7 @@
5
5
 
6
6
  import type {
7
7
  PendingPrompt,
8
+ PromptKind,
8
9
  RunSnapshot,
9
10
  StageSnapshot,
10
11
  StageNotice,
@@ -43,6 +44,23 @@ export interface RunEndMetadata {
43
44
  readonly resumable?: boolean;
44
45
  }
45
46
 
47
+ export interface PromptAnswerRecord {
48
+ readonly runId: string;
49
+ readonly stageId: string;
50
+ readonly promptId: string;
51
+ readonly kind: PromptKind;
52
+ readonly value: unknown;
53
+ readonly answeredAt: number;
54
+ }
55
+
56
+ export interface ResolveStagePendingPromptOptions {
57
+ /**
58
+ * Whether to retain the response in the live-only prompt answer ledger for
59
+ * continuation replay. Abort/default resolutions should set this to false.
60
+ */
61
+ readonly recordAnswer?: boolean;
62
+ }
63
+
46
64
  export interface Store {
47
65
  runs(): readonly RunSnapshot[];
48
66
  notices(): readonly WorkflowNotice[];
@@ -118,9 +136,20 @@ export interface Store {
118
136
  stageId: string,
119
137
  promptId: string,
120
138
  response: unknown,
139
+ options?: ResolveStagePendingPromptOptions,
121
140
  ): boolean;
122
141
  /** Wait for a stage/node-scoped HIL prompt to resolve. */
123
142
  awaitStagePendingPrompt(runId: string, stageId: string, promptId: string): Promise<unknown>;
143
+ /**
144
+ * Return the live-only prompt answer record for a completed prompt stage, if
145
+ * still available. The returned value may contain secrets and must never be
146
+ * logged, serialized, or copied into snapshots/persistence. Answers remain
147
+ * resident in memory until explicitly cleared, the run is removed, or the
148
+ * store is cleared.
149
+ */
150
+ getStagePromptAnswer(runId: string, stageId: string): PromptAnswerRecord | undefined;
151
+ /** Clear the live-only prompt answer record for a stage. Primarily used by tests/cleanup. */
152
+ clearStagePromptAnswer(runId: string, stageId: string): void;
124
153
  /**
125
154
  * Record Pi/pi SDK session metadata for a stage after lazy
126
155
  * attach. The serializable snapshot tracks this so post-mortem reopen
@@ -188,6 +217,7 @@ export function createStore(): Store {
188
217
  const _runs: RunSnapshot[] = [];
189
218
  const _notices: WorkflowNotice[] = [];
190
219
  const _listeners: Set<(snap: StoreSnapshot) => void> = new Set();
220
+ const _stagePromptAnswers = new Map<string, PromptAnswerRecord>();
191
221
  let _version = 0;
192
222
 
193
223
  /**
@@ -230,6 +260,10 @@ export function createStore(): Store {
230
260
  entry.reject(new Error(reason));
231
261
  }
232
262
 
263
+ function stagePromptAnswerKey(runId: string, stageId: string): string {
264
+ return JSON.stringify([runId, stageId]);
265
+ }
266
+
233
267
  function rejectStagePrompt(stage: StageSnapshot, reason: string): void {
234
268
  const prompt = stage.pendingPrompt;
235
269
  if (!prompt) return;
@@ -334,8 +368,10 @@ export function createStore(): Store {
334
368
  existing.failureKind = stage.failureKind;
335
369
  existing.failureMessage = stage.failureMessage;
336
370
  existing.skippedReason = stage.skippedReason;
337
- existing.replayedFromStageId = stage.replayedFromStageId;
338
- existing.replayed = stage.replayed;
371
+ if (stage.replayKey !== undefined) existing.replayKey = stage.replayKey;
372
+ if (stage.promptAnswerState !== undefined) existing.promptAnswerState = stage.promptAnswerState;
373
+ if (stage.replayedFromStageId !== undefined) existing.replayedFromStageId = stage.replayedFromStageId;
374
+ if (stage.replayed !== undefined) existing.replayed = stage.replayed;
339
375
  delete existing.awaitingInputSince;
340
376
  rejectStagePrompt(existing, `pi-workflows: stage ${stage.id} ended before prompt resolved`);
341
377
  _version++;
@@ -399,6 +435,9 @@ export function createStore(): Store {
399
435
  rejectPrompt(pending.id, `pi-workflows: run ${runId} was removed before prompt resolved`);
400
436
  }
401
437
  rejectAllStagePrompts(run, `pi-workflows: run ${runId} was removed before prompt resolved`);
438
+ for (const stage of run.stages) {
439
+ _stagePromptAnswers.delete(stagePromptAnswerKey(runId, stage.id));
440
+ }
402
441
  _runs.splice(index, 1);
403
442
  for (let i = _notices.length - 1; i >= 0; i--) {
404
443
  if (_notices[i]?.runId === runId) _notices.splice(i, 1);
@@ -485,6 +524,7 @@ export function createStore(): Store {
485
524
  if (isTerminalStageStatus(stage.status)) return false;
486
525
  if (stage.pendingPrompt !== undefined) return false;
487
526
  stage.pendingPrompt = { ...prompt };
527
+ stage.promptFootprint = { ...prompt };
488
528
  stage.status = "awaiting_input";
489
529
  stage.awaitingInputSince = prompt.createdAt;
490
530
  _version++;
@@ -497,6 +537,7 @@ export function createStore(): Store {
497
537
  stageId: string,
498
538
  promptId: string,
499
539
  response: unknown,
540
+ options: ResolveStagePendingPromptOptions = {},
500
541
  ): boolean {
501
542
  const run = findRun(runId);
502
543
  if (!run) return false;
@@ -504,6 +545,20 @@ export function createStore(): Store {
504
545
  if (!stage) return false;
505
546
  const pending = stage.pendingPrompt;
506
547
  if (!pending || pending.id !== promptId) return false;
548
+ if (options.recordAnswer !== false) {
549
+ _stagePromptAnswers.set(stagePromptAnswerKey(runId, stageId), {
550
+ runId,
551
+ stageId,
552
+ promptId,
553
+ kind: pending.kind,
554
+ value: response,
555
+ answeredAt: Date.now(),
556
+ });
557
+ stage.promptAnswerState = "available";
558
+ } else {
559
+ _stagePromptAnswers.delete(stagePromptAnswerKey(runId, stageId));
560
+ delete stage.promptAnswerState;
561
+ }
507
562
  stage.pendingPrompt = undefined;
508
563
  if (stage.status === "awaiting_input") {
509
564
  stage.status = "running";
@@ -544,6 +599,24 @@ export function createStore(): Store {
544
599
  });
545
600
  },
546
601
 
602
+ getStagePromptAnswer(runId: string, stageId: string): PromptAnswerRecord | undefined {
603
+ return _stagePromptAnswers.get(stagePromptAnswerKey(runId, stageId));
604
+ },
605
+
606
+ clearStagePromptAnswer(runId: string, stageId: string): void {
607
+ const removed = _stagePromptAnswers.delete(stagePromptAnswerKey(runId, stageId));
608
+ const run = findRun(runId);
609
+ const stage = run ? findStage(run, stageId) : undefined;
610
+ const clearAvailabilityMarker = stage?.promptAnswerState === "available";
611
+ if (clearAvailabilityMarker) {
612
+ delete stage.promptAnswerState;
613
+ }
614
+ if (removed || clearAvailabilityMarker) {
615
+ _version++;
616
+ notify();
617
+ }
618
+ },
619
+
547
620
  recordStageSession(
548
621
  runId: string,
549
622
  stageId: string,
@@ -742,7 +815,7 @@ export function createStore(): Store {
742
815
  },
743
816
 
744
817
  clear(): void {
745
- if (_runs.length === 0 && _notices.length === 0 && _resolvers.size === 0) return;
818
+ if (_runs.length === 0 && _notices.length === 0 && _resolvers.size === 0 && _stagePromptAnswers.size === 0) return;
746
819
  _runs.length = 0;
747
820
  _notices.length = 0;
748
821
  // Reject any outstanding HIL waiters so background promises terminate
@@ -752,6 +825,7 @@ export function createStore(): Store {
752
825
  entry.reject(new Error("pi-workflows: store cleared"));
753
826
  }
754
827
  _resolvers.clear();
828
+ _stagePromptAnswers.clear();
755
829
  _version++;
756
830
  notify();
757
831
  },
@@ -250,7 +250,8 @@ export class GraphView implements Component {
250
250
  }
251
251
 
252
252
  const previousFocusedStageId = this.cachedLayout[this.focusedIndex]?.stage.id;
253
- const nextLayout = computeLayout(run.stages, { orientation: "vertical" });
253
+ const graphStages = this._graphStages(run);
254
+ const nextLayout = computeLayout(graphStages, { orientation: "vertical" });
254
255
  this.cachedLayout = nextLayout;
255
256
 
256
257
  let focusNeedsReveal = this.pendingEnsureFocusedVisible;
@@ -289,6 +290,21 @@ export class GraphView implements Component {
289
290
  this._syncPromptState(run.pendingPrompt);
290
291
  }
291
292
 
293
+ private _graphStages(run: RunSnapshot): StageSnapshot[] {
294
+ const hasStagePrompt = run.stages.some((stage) => stage.pendingPrompt !== undefined);
295
+ if (!hasStagePrompt) return [...run.stages];
296
+ return run.stages.filter((stage) => {
297
+ // Prompt-node injection can leave unstarted author stages in the store
298
+ // while the prompt node owns focus; hide only these inert placeholders.
299
+ const isUnstartedPlaceholder =
300
+ stage.status === "pending" &&
301
+ stage.startedAt === undefined &&
302
+ stage.pendingPrompt === undefined &&
303
+ stage.toolEvents.length === 0;
304
+ return !isUnstartedPlaceholder;
305
+ });
306
+ }
307
+
292
308
  /**
293
309
  * Mirror the run's `pendingPrompt` into a UI working state. A new prompt
294
310
  * id resets the state (caret + buffer); a cleared prompt drops the state