@bastani/atomic 0.8.27-alpha.1 → 0.8.28-alpha.1

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 (112) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/builtin/intercom/CHANGELOG.md +6 -0
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/CHANGELOG.md +6 -0
  5. package/dist/builtin/mcp/package.json +2 -2
  6. package/dist/builtin/subagents/CHANGELOG.md +6 -0
  7. package/dist/builtin/subagents/package.json +1 -1
  8. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  9. package/dist/builtin/web-access/package.json +1 -1
  10. package/dist/builtin/workflows/CHANGELOG.md +20 -0
  11. package/dist/builtin/workflows/README.md +11 -9
  12. package/dist/builtin/workflows/package.json +1 -1
  13. package/dist/builtin/workflows/src/authoring.d.ts +5 -2
  14. package/dist/builtin/workflows/src/extension/background-ui-adapter.ts +3 -1
  15. package/dist/builtin/workflows/src/extension/hil-answer-notifications.ts +17 -25
  16. package/dist/builtin/workflows/src/extension/index.ts +133 -18
  17. package/dist/builtin/workflows/src/extension/render-result.ts +22 -2
  18. package/dist/builtin/workflows/src/extension/workflow-schema.ts +3 -3
  19. package/dist/builtin/workflows/src/runs/foreground/executor.ts +210 -16
  20. package/dist/builtin/workflows/src/sdk-surface.ts +1 -1
  21. package/dist/builtin/workflows/src/shared/authoring-contract.d.ts +42 -5
  22. package/dist/builtin/workflows/src/shared/store-types.ts +8 -2
  23. package/dist/builtin/workflows/src/shared/store.ts +51 -0
  24. package/dist/builtin/workflows/src/shared/types.ts +14 -4
  25. package/dist/builtin/workflows/src/tui/graph-view.ts +4 -1
  26. package/dist/builtin/workflows/src/tui/prompt-card.ts +6 -0
  27. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +11 -1
  28. package/dist/core/agent-session.d.ts +4 -4
  29. package/dist/core/agent-session.d.ts.map +1 -1
  30. package/dist/core/agent-session.js +147 -31
  31. package/dist/core/agent-session.js.map +1 -1
  32. package/dist/core/auth-guidance.d.ts +10 -1
  33. package/dist/core/auth-guidance.d.ts.map +1 -1
  34. package/dist/core/auth-guidance.js +26 -1
  35. package/dist/core/auth-guidance.js.map +1 -1
  36. package/dist/core/compaction/branch-summarization.d.ts +2 -2
  37. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  38. package/dist/core/compaction/branch-summarization.js +7 -7
  39. package/dist/core/compaction/branch-summarization.js.map +1 -1
  40. package/dist/core/compaction/compaction.d.ts +4 -84
  41. package/dist/core/compaction/compaction.d.ts.map +1 -1
  42. package/dist/core/compaction/compaction.js +3 -479
  43. package/dist/core/compaction/compaction.js.map +1 -1
  44. package/dist/core/compaction/context-compaction.d.ts.map +1 -1
  45. package/dist/core/compaction/context-compaction.js +39 -82
  46. package/dist/core/compaction/context-compaction.js.map +1 -1
  47. package/dist/core/compaction/index.d.ts +1 -1
  48. package/dist/core/compaction/index.d.ts.map +1 -1
  49. package/dist/core/compaction/index.js +1 -1
  50. package/dist/core/compaction/index.js.map +1 -1
  51. package/dist/core/extensions/types.d.ts +10 -8
  52. package/dist/core/extensions/types.d.ts.map +1 -1
  53. package/dist/core/extensions/types.js.map +1 -1
  54. package/dist/core/index.d.ts +1 -1
  55. package/dist/core/index.d.ts.map +1 -1
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/core/messages.d.ts +1 -11
  58. package/dist/core/messages.d.ts.map +1 -1
  59. package/dist/core/messages.js +10 -25
  60. package/dist/core/messages.js.map +1 -1
  61. package/dist/core/session-manager.d.ts +5 -8
  62. package/dist/core/session-manager.d.ts.map +1 -1
  63. package/dist/core/session-manager.js +12 -76
  64. package/dist/core/session-manager.js.map +1 -1
  65. package/dist/core/settings-manager.d.ts +0 -3
  66. package/dist/core/settings-manager.d.ts.map +1 -1
  67. package/dist/core/settings-manager.js +0 -4
  68. package/dist/core/settings-manager.js.map +1 -1
  69. package/dist/index.d.ts +3 -3
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +3 -3
  72. package/dist/index.js.map +1 -1
  73. package/dist/modes/interactive/components/chat-message-renderer.d.ts +1 -5
  74. package/dist/modes/interactive/components/chat-message-renderer.d.ts.map +1 -1
  75. package/dist/modes/interactive/components/chat-message-renderer.js +5 -9
  76. package/dist/modes/interactive/components/chat-message-renderer.js.map +1 -1
  77. package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
  78. package/dist/modes/interactive/components/chat-session-host.js +0 -3
  79. package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
  80. package/dist/modes/interactive/components/index.d.ts +0 -1
  81. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  82. package/dist/modes/interactive/components/index.js +0 -1
  83. package/dist/modes/interactive/components/index.js.map +1 -1
  84. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  85. package/dist/modes/interactive/interactive-mode.js +4 -27
  86. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  87. package/dist/modes/rpc/rpc-client.d.ts +1 -1
  88. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  89. package/dist/modes/rpc/rpc-client.js +2 -2
  90. package/dist/modes/rpc/rpc-client.js.map +1 -1
  91. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  92. package/dist/modes/rpc/rpc-mode.js +1 -1
  93. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  94. package/dist/modes/rpc/rpc-types.d.ts +0 -1
  95. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  96. package/dist/modes/rpc/rpc-types.js.map +1 -1
  97. package/docs/compaction.md +210 -181
  98. package/docs/extensions.md +31 -20
  99. package/docs/json.md +3 -4
  100. package/docs/session-format.md +12 -21
  101. package/docs/sessions.md +3 -1
  102. package/docs/settings.md +2 -5
  103. package/docs/workflows.md +11 -9
  104. package/examples/extensions/README.md +1 -1
  105. package/examples/extensions/custom-compaction.ts +43 -106
  106. package/examples/extensions/handoff.ts +6 -44
  107. package/examples/extensions/trigger-compact.ts +5 -4
  108. package/package.json +5 -5
  109. package/dist/modes/interactive/components/compaction-summary-message.d.ts +0 -16
  110. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +0 -1
  111. package/dist/modes/interactive/components/compaction-summary-message.js +0 -43
  112. package/dist/modes/interactive/components/compaction-summary-message.js.map +0 -1
@@ -110,11 +110,13 @@ type StageListItem = {
110
110
  awaitingInputSince?: number;
111
111
  pendingPrompt?: PendingPrompt;
112
112
  inputRequest?: StageInputRequest;
113
+ promptFootprint?: PendingPrompt;
113
114
  };
114
115
  type StageListResult = { action: "stages"; runId: string; filter: string; stages: StageListItem[]; error?: string };
115
116
  type StageDetailItem = StageSnapshot & { transcriptPath?: string };
116
117
  type StageDetailResult = { action: "stage"; runId: string; stage?: StageDetailItem; error?: string };
117
118
  type TranscriptEntry = { role: string; text?: string; toolName?: string; output?: string; timestamp?: number };
119
+ type TranscriptInlineMode = "path_only" | "preview" | "fallback_preview";
118
120
  type TranscriptResult = {
119
121
  action: "transcript";
120
122
  runId: string;
@@ -127,6 +129,9 @@ type TranscriptResult = {
127
129
  sessionId?: string;
128
130
  sessionFile?: string;
129
131
  transcriptPath?: string;
132
+ lazyReadPrompt?: string;
133
+ fallbackNote?: string;
134
+ inlineMode?: TranscriptInlineMode;
130
135
  };
131
136
  type SendResult = { action: "send"; runId: string; stageId: string; delivery: string; status: "ok" | "noop"; message: string };
132
137
  type PauseResult = { action: "pause"; runId: string; status: string; message: string };
@@ -213,7 +218,7 @@ function renderNotice(
213
218
  const TRANSCRIPT_NOTICE_ENTRY_LIMIT = 5;
214
219
  const TRANSCRIPT_NOTICE_CHAR_LIMIT = 240;
215
220
 
216
- function transcriptNoticeText(entries: readonly TranscriptEntry[]): string {
221
+ function transcriptEntriesNoticeText(entries: readonly TranscriptEntry[]): string {
217
222
  if (entries.length === 0) return "no transcript entries";
218
223
  const shown = entries.slice(0, TRANSCRIPT_NOTICE_ENTRY_LIMIT);
219
224
  const text = shown
@@ -225,6 +230,21 @@ function transcriptNoticeText(entries: readonly TranscriptEntry[]): string {
225
230
  return fitLine(`${text}${entrySuffix}`, TRANSCRIPT_NOTICE_CHAR_LIMIT);
226
231
  }
227
232
 
233
+ function transcriptNoticeText(result: TranscriptResult): string {
234
+ if ((result.inlineMode === "path_only" || result.lazyReadPrompt !== undefined) && result.entries.length === 0) {
235
+ const path = result.transcriptPath ?? result.sessionFile ?? "transcript file";
236
+ const count = result.entryCount === undefined
237
+ ? ""
238
+ : ` (${result.entryCount} ${result.entryCount === 1 ? "entry" : "entries"})`;
239
+ return fitLine(`not inlined; read ${path}${count}`, TRANSCRIPT_NOTICE_CHAR_LIMIT);
240
+ }
241
+ const entriesText = transcriptEntriesNoticeText(result.entries);
242
+ if (result.inlineMode === "fallback_preview" || result.fallbackNote !== undefined) {
243
+ return fitLine(`no session file; preview: ${entriesText}`, TRANSCRIPT_NOTICE_CHAR_LIMIT);
244
+ }
245
+ return entriesText;
246
+ }
247
+
228
248
  export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts): string {
229
249
  const partial = opts?.isPartial === true;
230
250
  const themed = opts?.plain !== true;
@@ -350,7 +370,7 @@ export function renderResult(result: WorkflowToolResult, opts?: RenderResultOpts
350
370
 
351
371
  case "transcript": {
352
372
  const r = result as TranscriptResult;
353
- const text = transcriptNoticeText(r.entries);
373
+ const text = transcriptNoticeText(r);
354
374
  const suffix = r.truncated ? " (truncated)" : "";
355
375
  return renderNotice("WORKFLOW TRANSCRIPT", `${r.runId}/${r.stageId.slice(0, 12)} ${r.source}: ${text}${suffix}`, opts, themed);
356
376
  }
@@ -114,7 +114,7 @@ export const WorkflowParametersSchema = Type.Object({
114
114
  Type.Literal("resume"),
115
115
  Type.Literal("reload"),
116
116
  ], {
117
- description: "Workflow action: run/list/get/inputs/status, inspect stage metadata, send messages or prompt answers, pause/resume/interrupt/kill runs, or reload workflow resources. For transcript inspection, prefer status/stages/stage first to get sessionFile/transcriptPath, quote the exact path without rewriting separators (Windows backslashes are valid), then search it with rg/grep and read small ranges; transcript defaults to at most 5 recent entries and explicit tail/limit overrides that preview.",
117
+ description: "Workflow action: run/list/get/inputs/status, inspect stage metadata, send messages or prompt answers, pause/resume/interrupt/kill runs, or reload workflow resources. For transcript inspection, prefer status/stages/stage first to get sessionFile/transcriptPath, quote the exact path without rewriting separators (Windows backslashes are valid), then search it with rg/grep and read small ranges; transcript is path-only by default when sessionFile/transcriptPath exists, explicit tail/limit returns bounded previews, and missing transcript paths fall back to a small preview.",
118
118
  })),
119
119
  runId: Type.Optional(Type.String({
120
120
  description: "Run identifier or unique prefix for status/stages/stage/transcript/send/pause/resume/interrupt/kill. Use '--all' or all:true for supported bulk run-control actions.",
@@ -146,14 +146,14 @@ export const WorkflowParametersSchema = Type.Object({
146
146
  })),
147
147
  limit: Type.Optional(Type.Integer({
148
148
  minimum: 0,
149
- description: "Transcript-only: explicitly inline at most this many recent entries. Omit both limit and tail to use the default 5-entry preview plus metadata/path; prefer rg/grep on the exact quoted sessionFile/transcriptPath for targeted lookup without rewriting platform path separators.",
149
+ description: "Transcript-only: explicitly inline at most this many recent entries. Omit both limit and tail to use the path-only default when sessionFile/transcriptPath exists; prefer rg/grep on the exact quoted sessionFile/transcriptPath for targeted lookup without rewriting platform path separators.",
150
150
  })),
151
151
  tail: Type.Optional(Type.Integer({
152
152
  minimum: 0,
153
153
  description: "Transcript-only: explicitly inline the last N entries; overrides limit. Use for quick recent-context checks after status/stages/stage expose the transcript path.",
154
154
  })),
155
155
  includeToolOutput: Type.Optional(Type.Boolean({
156
- description: "Transcript-only: include captured tool output entries when building transcript results from stage snapshots; prefer rg/grep on the exact quoted sessionFile/transcriptPath for large outputs. Live session transcripts may not expose tool output.",
156
+ description: "Transcript-only: include captured tool output entries when building inlined snapshot previews; this does not bypass the path-only default. Prefer rg/grep on the exact quoted sessionFile/transcriptPath for large outputs. Live session transcripts may not expose tool output.",
157
157
  })),
158
158
  text: Type.Optional(Type.String({
159
159
  description: "Text to send to a stage for prompt answers, steering, follow-ups, or resume messages.",
@@ -14,6 +14,8 @@ import type {
14
14
  WorkflowRunContext,
15
15
  WorkflowUIContext,
16
16
  WorkflowUIAdapter,
17
+ WorkflowCustomUiFactory,
18
+ WorkflowCustomUiOptions,
17
19
  WorkflowInputSchema,
18
20
  StageContext,
19
21
  StageOptions,
@@ -56,7 +58,7 @@ import type {
56
58
  WorkflowFailureRecoverability,
57
59
  WorkflowFailureDisposition,
58
60
  PendingPrompt,
59
- PromptKind,
61
+ CustomPromptIdentitySource,
60
62
  WorkflowChildReplaySnapshot,
61
63
  WorkflowChildRunRef,
62
64
  } from "../../shared/store-types.js";
@@ -108,7 +110,7 @@ export interface RunContinuationOpts {
108
110
  readonly resumeFromStageId: string;
109
111
  }
110
112
 
111
- export interface RunOpts extends Omit<AuthoringContract.RunOpts, "adapters" | "store" | "cancellation" | "overlay" | "registry" | "stageControlRegistry" | "continuation" | "onRunStart" | "onStageStart" | "onStageEnd" | "onRunEnd"> {
113
+ export interface RunOpts extends Omit<AuthoringContract.RunOpts, "adapters" | "store" | "cancellation" | "overlay" | "registry" | "stageControlRegistry" | "continuation" | "onRunStart" | "onStageStart" | "onStageEnd" | "onRunEnd" | "ui"> {
112
114
  adapters?: StageAdapters;
113
115
  /** Invocation working directory exposed to workflow definitions as ctx.cwd. */
114
116
  cwd?: string;
@@ -271,14 +273,28 @@ function resolveInputRuntimeDefaults(
271
273
  // HIL unavailable fallback — rejects with precise per-primitive error
272
274
  // ---------------------------------------------------------------------------
273
275
 
274
- interface PromptDescriptor {
275
- readonly kind: PromptKind;
276
+ type PrimitivePromptDescriptor =
277
+ | { readonly kind: "input"; readonly message: string; readonly initial?: string }
278
+ | { readonly kind: "confirm"; readonly message: string }
279
+ | { readonly kind: "select"; readonly message: string; readonly choices: readonly string[] }
280
+ | { readonly kind: "editor"; readonly message: string; readonly initial?: string };
281
+
282
+ interface CustomPromptDescriptor<T> {
283
+ readonly kind: "custom";
276
284
  readonly message: string;
277
- readonly choices?: readonly string[];
278
- readonly initial?: string;
285
+ readonly factory: WorkflowCustomUiFactory<T>;
286
+ readonly options?: WorkflowCustomUiOptions;
287
+ readonly customIdentityHash: string;
288
+ readonly customIdentitySource: CustomPromptIdentitySource;
279
289
  }
280
290
 
281
- function fallbackForPromptDescriptor(descriptor: PromptDescriptor): unknown {
291
+ type PromptDescriptor<T = unknown> = PrimitivePromptDescriptor | CustomPromptDescriptor<T>;
292
+
293
+ function isCustomPromptDescriptor<T>(descriptor: PromptDescriptor<T>): descriptor is CustomPromptDescriptor<T> {
294
+ return descriptor.kind === "custom";
295
+ }
296
+
297
+ function fallbackForPromptDescriptor(descriptor: PrimitivePromptDescriptor): unknown {
282
298
  switch (descriptor.kind) {
283
299
  case "input":
284
300
  case "editor":
@@ -286,7 +302,7 @@ function fallbackForPromptDescriptor(descriptor: PromptDescriptor): unknown {
286
302
  case "confirm":
287
303
  return false;
288
304
  case "select":
289
- return descriptor.choices?.[0] ?? "";
305
+ return descriptor.choices[0] ?? "";
290
306
  }
291
307
  }
292
308
 
@@ -295,8 +311,12 @@ function makePrompt(descriptor: PromptDescriptor): PendingPrompt {
295
311
  id: `hil-${crypto.randomUUID()}`,
296
312
  kind: descriptor.kind,
297
313
  message: descriptor.message,
298
- ...(descriptor.choices !== undefined ? { choices: descriptor.choices } : {}),
299
- ...(descriptor.initial !== undefined ? { initial: descriptor.initial } : {}),
314
+ ...(!isCustomPromptDescriptor(descriptor) && descriptor.kind === "select" ? { choices: descriptor.choices } : {}),
315
+ ...(!isCustomPromptDescriptor(descriptor) && (descriptor.kind === "input" || descriptor.kind === "editor") && descriptor.initial !== undefined ? { initial: descriptor.initial } : {}),
316
+ ...(isCustomPromptDescriptor(descriptor) ? {
317
+ customIdentityHash: descriptor.customIdentityHash,
318
+ customIdentitySource: descriptor.customIdentitySource,
319
+ } : {}),
300
320
  createdAt: Date.now(),
301
321
  };
302
322
  }
@@ -307,13 +327,19 @@ function stableHash(value: unknown): string {
307
327
  }
308
328
 
309
329
  function promptDescriptorHash(descriptor: PromptDescriptor): string {
330
+ if (isCustomPromptDescriptor(descriptor)) {
331
+ return stableHash({
332
+ kind: "custom",
333
+ customIdentityHash: descriptor.customIdentityHash,
334
+ });
335
+ }
310
336
  return stableHash({
311
337
  kind: descriptor.kind,
312
338
  message: descriptor.message,
313
- choices: descriptor.choices ?? [],
339
+ choices: descriptor.kind === "select" ? descriptor.choices : [],
314
340
  // Include input/editor initial text because it is visible prompt context;
315
341
  // changing it should not replay a stale answer from the same callsite.
316
- initial: descriptor.initial ?? null,
342
+ initial: descriptor.kind === "input" || descriptor.kind === "editor" ? descriptor.initial ?? null : null,
317
343
  });
318
344
  }
319
345
 
@@ -334,6 +360,80 @@ function hilAbortError(signal: AbortSignal): Error {
334
360
  : new Error("atomic-workflows: HIL aborted");
335
361
  }
336
362
 
363
+ function resolveCustomPromptIdentity<T>(
364
+ factory: WorkflowCustomUiFactory<T>,
365
+ options: WorkflowCustomUiOptions | undefined,
366
+ ): Pick<CustomPromptDescriptor<T>, "customIdentityHash" | "customIdentitySource"> {
367
+ const replayIdentity = options?.replayIdentity?.trim();
368
+ if (replayIdentity !== undefined && replayIdentity.length > 0) {
369
+ return {
370
+ customIdentityHash: stableHash({ source: "caller", value: replayIdentity }),
371
+ customIdentitySource: "caller",
372
+ };
373
+ }
374
+ if (factory.name.trim().length > 0) {
375
+ return {
376
+ customIdentityHash: stableHash({ source: "factory", value: factory.name }),
377
+ customIdentitySource: "factory",
378
+ };
379
+ }
380
+ try {
381
+ const source = Function.prototype.toString.call(factory);
382
+ if (source.trim().length > 0) {
383
+ return {
384
+ customIdentityHash: stableHash({ source: "factory", value: source }),
385
+ customIdentitySource: "factory",
386
+ };
387
+ }
388
+ } catch {
389
+ // Fall through to callsite-only identity below.
390
+ }
391
+ return {
392
+ customIdentityHash: stableHash({ source: "callsite" }),
393
+ customIdentitySource: "callsite",
394
+ };
395
+ }
396
+
397
+ function customPromptDescriptor<T>(
398
+ factory: WorkflowCustomUiFactory<T>,
399
+ options: WorkflowCustomUiOptions | undefined,
400
+ ): CustomPromptDescriptor<T> {
401
+ const label = options?.label?.trim();
402
+ return {
403
+ kind: "custom",
404
+ message: label && label.length > 0 ? label : "Custom TUI prompt",
405
+ factory,
406
+ ...(options !== undefined ? { options } : {}),
407
+ ...resolveCustomPromptIdentity(factory, options),
408
+ };
409
+ }
410
+
411
+ interface MergedHilSignal {
412
+ readonly signal: AbortSignal;
413
+ readonly dispose: () => void;
414
+ }
415
+
416
+ function mergeHilSignals(primary: AbortSignal, secondary: AbortSignal | undefined): MergedHilSignal {
417
+ if (secondary === undefined) return { signal: primary, dispose: () => undefined };
418
+ const controller = new AbortController();
419
+ const abortFrom = (source: AbortSignal): void => {
420
+ if (!controller.signal.aborted) controller.abort(source.reason);
421
+ };
422
+ const onPrimaryAbort = (): void => abortFrom(primary);
423
+ const onSecondaryAbort = (): void => abortFrom(secondary);
424
+ primary.addEventListener("abort", onPrimaryAbort, { once: true });
425
+ secondary.addEventListener("abort", onSecondaryAbort, { once: true });
426
+ if (primary.aborted) abortFrom(primary);
427
+ else if (secondary.aborted) abortFrom(secondary);
428
+ return {
429
+ signal: controller.signal,
430
+ dispose: () => {
431
+ primary.removeEventListener("abort", onPrimaryAbort);
432
+ secondary.removeEventListener("abort", onSecondaryAbort);
433
+ },
434
+ };
435
+ }
436
+
337
437
  function makeUnavailableUIContext(): WorkflowUIContext {
338
438
  const msg = (primitive: string): string =>
339
439
  `atomic-workflows: HIL ctx.ui.${primitive} is unavailable because Atomic runtime did not provide a UI adapter`;
@@ -342,6 +442,31 @@ function makeUnavailableUIContext(): WorkflowUIContext {
342
442
  confirm: () => Promise.reject(new Error(msg("confirm"))),
343
443
  select: () => Promise.reject(new Error(msg("select"))),
344
444
  editor: () => Promise.reject(new Error(msg("editor"))),
445
+ custom: () => Promise.reject(new Error(msg("custom"))),
446
+ };
447
+ }
448
+
449
+ function normalizeUIContext(adapter: WorkflowUIAdapter | undefined): WorkflowUIContext {
450
+ const unavailable = makeUnavailableUIContext();
451
+ if (adapter === undefined) return unavailable;
452
+ return {
453
+ input(prompt) {
454
+ return adapter.input.call(adapter, prompt);
455
+ },
456
+ confirm(message) {
457
+ return adapter.confirm.call(adapter, message);
458
+ },
459
+ select<T extends string>(message: string, options: readonly T[]): Promise<T> {
460
+ return adapter.select.call(adapter, message, options) as Promise<T>;
461
+ },
462
+ editor(initial) {
463
+ return adapter.editor.call(adapter, initial);
464
+ },
465
+ custom<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T> {
466
+ return adapter.custom !== undefined
467
+ ? adapter.custom.call(adapter, factory, options) as Promise<T>
468
+ : unavailable.custom(factory, options);
469
+ },
345
470
  };
346
471
  }
347
472
 
@@ -1400,8 +1525,19 @@ export async function runChain(
1400
1525
  return workflowDetailsFromRun("chain", runResult, results, options, validationWarnings);
1401
1526
  }
1402
1527
 
1403
- function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
1528
+ export function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
1404
1529
  if (signal.aborted) {
1530
+ // Callers invoke `raceAbort(call(), signal)`, so `call()` is evaluated —
1531
+ // and the underlying work (e.g. a stage prompt) is already in flight —
1532
+ // before this function observes an already-aborted signal. Attach a no-op
1533
+ // rejection handler so that in-flight promise can never surface as an
1534
+ // unhandled rejection. Without this, killing a workflow mid-prompt orphans
1535
+ // the prompt promise; its eventual rejection (commonly
1536
+ // "No API key found for ...") escapes every workflow error boundary and is
1537
+ // raised as a process-level uncaught exception that crashes the whole CLI.
1538
+ // The run is being aborted, so the orphaned settlement is intentionally
1539
+ // discarded here.
1540
+ void promise.catch(() => {});
1405
1541
  return Promise.reject(signal.reason ?? new DOMException("workflow killed", "AbortError"));
1406
1542
  }
1407
1543
  return new Promise<T>((resolve, reject) => {
@@ -2640,11 +2776,16 @@ export async function run<TInputs extends WorkflowInputValues>(
2640
2776
  };
2641
2777
  };
2642
2778
 
2643
- const buildPromptNodeUiAdapter = (): WorkflowUIAdapter => {
2644
- const ask = async (descriptor: PromptDescriptor): Promise<unknown> => {
2779
+ const buildPromptNodeUiAdapter = (): WorkflowUIContext => {
2780
+ const ask = async <T>(descriptor: PromptDescriptor<T>): Promise<unknown> => {
2781
+ const isCustom = isCustomPromptDescriptor(descriptor);
2645
2782
  if (ownController.signal.aborted) {
2783
+ if (isCustom) throw hilAbortError(ownController.signal);
2646
2784
  return fallbackForPromptDescriptor(descriptor);
2647
2785
  }
2786
+ if (isCustom && descriptor.options?.signal?.aborted) {
2787
+ throw hilAbortError(descriptor.options.signal);
2788
+ }
2648
2789
 
2649
2790
  const prompt = makePrompt(descriptor);
2650
2791
  const stageId = crypto.randomUUID();
@@ -2741,6 +2882,55 @@ export async function run<TInputs extends WorkflowInputValues>(
2741
2882
  finalizePromptStage("completed");
2742
2883
  return replayAnswer.value;
2743
2884
  }
2885
+
2886
+ if (isCustom) {
2887
+ if (descriptor.options?.overlay === true) {
2888
+ const error = new Error("atomic-workflows: ctx.ui.custom overlay mode is unavailable in the workflow graph viewer");
2889
+ applyFailureToStage(stageSnapshot, classifyExecutorFailure(error));
2890
+ finalizePromptStage("failed");
2891
+ throw error;
2892
+ }
2893
+
2894
+ const mergedSignal = mergeHilSignals(ownController.signal, descriptor.options?.signal);
2895
+ try {
2896
+ if (mergedSignal.signal.aborted) throw hilAbortError(mergedSignal.signal);
2897
+ const accepted = activeStore.recordStageAwaitingInput(runId, stageId, true, prompt.createdAt);
2898
+ if (!accepted) {
2899
+ const error = new Error("atomic-workflows: ctx.ui.custom prompt node is unavailable");
2900
+ stageSnapshot.skippedReason = "prompt-unavailable";
2901
+ finalizePromptStage("skipped");
2902
+ throw error;
2903
+ }
2904
+ const response = await stageUiBroker.requestCustomUi(
2905
+ runId,
2906
+ stageId,
2907
+ descriptor.factory as unknown as Parameters<typeof stageUiBroker.requestCustomUi>[2],
2908
+ descriptor.options as Parameters<typeof stageUiBroker.requestCustomUi>[3],
2909
+ mergedSignal.signal,
2910
+ );
2911
+ activeStore.recordStagePromptAnswer(runId, stageId, prompt, response, {
2912
+ answerSource: "workflow_ui",
2913
+ });
2914
+ finalizePromptStage("completed");
2915
+ return response;
2916
+ } catch (err) {
2917
+ activeStore.recordStageAwaitingInput(runId, stageId, false);
2918
+ stageUiBroker.cancelStagePrompt(runId, stageId, err);
2919
+ if (mergedSignal.signal.aborted) {
2920
+ stageSnapshot.skippedReason = ownController.signal.aborted ? "run-aborted" : "prompt-aborted";
2921
+ finalizePromptStage("skipped");
2922
+ throw hilAbortError(mergedSignal.signal);
2923
+ }
2924
+ if (!finalized) {
2925
+ applyFailureToStage(stageSnapshot, classifyExecutorFailure(err));
2926
+ finalizePromptStage("failed");
2927
+ }
2928
+ throw err;
2929
+ } finally {
2930
+ mergedSignal.dispose();
2931
+ }
2932
+ }
2933
+
2744
2934
  const accepted = activeStore.recordStagePendingPrompt(runId, stageId, prompt);
2745
2935
  if (!accepted) {
2746
2936
  stageSnapshot.skippedReason = "prompt-unavailable";
@@ -2818,6 +3008,10 @@ export async function run<TInputs extends WorkflowInputValues>(
2818
3008
  });
2819
3009
  return typeof response === "string" ? response : initial ?? "";
2820
3010
  },
3011
+ async custom<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T> {
3012
+ const response = await ask(customPromptDescriptor(factory, options));
3013
+ return response as T;
3014
+ },
2821
3015
  };
2822
3016
  };
2823
3017
 
@@ -2827,7 +3021,7 @@ export async function run<TInputs extends WorkflowInputValues>(
2827
3021
  get cwd() { return resolveWorkflowCwd(); },
2828
3022
  // Prompt nodes and caller-provided UI adapters are mutually exclusive;
2829
3023
  // executor-owned prompt nodes intentionally take precedence when enabled.
2830
- ui: opts.usePromptNodesForUi === true ? buildPromptNodeUiAdapter() : opts.ui ?? makeUnavailableUIContext(),
3024
+ ui: opts.usePromptNodesForUi === true ? buildPromptNodeUiAdapter() : normalizeUIContext(opts.ui),
2831
3025
 
2832
3026
  stage(name: string, options?: StageOptions, stageFailFastScope?: ParallelFailFastScope) {
2833
3027
  options = stageOptionsWithGitWorktree(stageOptionsWithInputDefaults(options, inputRuntimeDefaults), workflowInvocationCwd);
@@ -37,7 +37,7 @@ export type { StageNode } from "./runs/shared/graph-inference.js";
37
37
  export { setupGitWorktree } from "./runs/shared/worktree.js";
38
38
  export type { GitWorktreeSetupOptions, GitWorktreeSetupResult } from "./runs/shared/worktree.js";
39
39
  export { createStore, store } from "./shared/store.js";
40
- export type { RunStatus, StageStatus, ToolEvent, StageSnapshot, RunSnapshot, StoreSnapshot, WorkflowNotice, NoticeLevel, WorkflowOverlayAdapter, PromptKind, PendingPrompt } from "./shared/store-types.js";
40
+ export type { RunStatus, StageStatus, ToolEvent, StageSnapshot, RunSnapshot, StoreSnapshot, WorkflowNotice, NoticeLevel, WorkflowOverlayAdapter, PromptKind, CustomPromptIdentitySource, PendingPrompt } from "./shared/store-types.js";
41
41
 
42
42
  // Phase D — cancellation registry
43
43
  export { createCancellationRegistry, cancellationRegistry } from "./runs/background/cancellation-registry.js";
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Dependency-light workflow authoring contract shared by the runtime type graph
3
- * and the standalone package typing surface.
2
+ * Workflow authoring contract shared by the runtime type graph and the
3
+ * standalone package typing surface.
4
4
  *
5
- * This module intentionally imports only TypeBox types. Do not import
6
- * @bastani/atomic, executor internals, stores, or runtime graph modules here.
5
+ * This module intentionally avoids executor internals, stores, or runtime graph
6
+ * modules here. Public custom-TUI types are type-only imports from the same
7
+ * extension-compatible surfaces used by Atomic extension UI.
7
8
  */
9
+ import type { KeybindingsManager, Theme } from "@bastani/atomic";
10
+ import type { Component, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
8
11
  import type { Static, TOptional, TSchema } from "typebox";
9
12
  export type { Static, TSchema };
10
13
  export type WorkflowSerializablePrimitive = string | number | boolean | null;
@@ -335,13 +338,47 @@ export interface WorkflowChildResult<TOutputs extends WorkflowOutputValues = Wor
335
338
  readonly status: "completed";
336
339
  readonly outputs: TOutputs;
337
340
  }
341
+ export type WorkflowCustomUiComponent = Component & {
342
+ dispose?(): void;
343
+ };
344
+ export type WorkflowCustomUiTui = TUI;
345
+ export type WorkflowCustomUiTheme = Theme;
346
+ export type WorkflowCustomUiKeybindings = KeybindingsManager;
347
+ export type WorkflowCustomUiOverlayOptions = OverlayOptions;
348
+ export type WorkflowCustomUiOverlayHandle = OverlayHandle;
349
+ export type WorkflowCustomUiFactory<T> = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (value: T) => void) => WorkflowCustomUiComponent | Promise<WorkflowCustomUiComponent>;
350
+ export interface WorkflowCustomUiOptions {
351
+ /** Render as a nested overlay. Workflow graph hosts may reject this when unsupported. */
352
+ readonly overlay?: boolean;
353
+ /** AbortSignal to programmatically dismiss the custom UI. */
354
+ readonly signal?: AbortSignal;
355
+ /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */
356
+ readonly overlayOptions?: OverlayOptions | (() => OverlayOptions);
357
+ /** Called with the real overlay handle after an overlay is shown. */
358
+ readonly onHandle?: (handle: OverlayHandle) => void;
359
+ /**
360
+ * Workflow-only replay identity. Recommended whenever widget state or
361
+ * semantics can change without the callsite changing. Do not include secrets;
362
+ * the runtime stores only a hash.
363
+ */
364
+ readonly replayIdentity?: string;
365
+ /** Safe display-only label for graph/status surfaces. Defaults to "Custom TUI prompt". Not part of replay identity. */
366
+ readonly label?: string;
367
+ }
338
368
  export interface WorkflowUIContext {
339
369
  input(prompt: string): Promise<string>;
340
370
  confirm(message: string): Promise<boolean>;
341
371
  select<T extends string>(message: string, options: readonly T[]): Promise<T>;
342
372
  editor(initial?: string): Promise<string>;
373
+ custom<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T>;
374
+ }
375
+ export interface WorkflowUIAdapter {
376
+ input(prompt: string): Promise<string>;
377
+ confirm(message: string): Promise<boolean>;
378
+ select<T extends string>(message: string, options: readonly T[]): Promise<T>;
379
+ editor(initial?: string): Promise<string>;
380
+ custom?<T>(factory: WorkflowCustomUiFactory<T>, options?: WorkflowCustomUiOptions): Promise<T>;
343
381
  }
344
- export type WorkflowUIAdapter = WorkflowUIContext;
345
382
  export interface WorkflowRunContext<TInputs extends WorkflowInputValues = WorkflowInputValues, TDefinitionBrand extends object = {}> {
346
383
  readonly inputs: Readonly<TInputs>;
347
384
  readonly cwd?: string;
@@ -32,10 +32,12 @@ export type WorkflowFailureCode =
32
32
  | "unknown";
33
33
 
34
34
  /**
35
- * Human-in-the-loop prompt kind. Mirrors the four `WorkflowUIContext` methods.
35
+ * Human-in-the-loop prompt kind. Mirrors the `WorkflowUIContext` methods.
36
36
  * cross-ref: src/shared/types.ts WorkflowUIContext
37
37
  */
38
- export type PromptKind = "input" | "confirm" | "select" | "editor";
38
+ export type PromptKind = "input" | "confirm" | "select" | "editor" | "custom";
39
+
40
+ export type CustomPromptIdentitySource = "caller" | "factory" | "callsite";
39
41
 
40
42
  /**
41
43
  * A pending HIL prompt awaiting user response. Surfaced through the graph
@@ -53,6 +55,10 @@ export interface PendingPrompt {
53
55
  readonly choices?: readonly string[];
54
56
  /** Initial value for `kind: "input"` and `kind: "editor"`. */
55
57
  readonly initial?: string;
58
+ /** Hash of caller-supplied or derived replay identity for `kind: "custom"`. */
59
+ readonly customIdentityHash?: string;
60
+ /** Explains how a custom prompt replay identity was derived without storing the raw identity. */
61
+ readonly customIdentitySource?: CustomPromptIdentitySource;
56
62
  /** Issue timestamp (ms since epoch). */
57
63
  readonly createdAt: number;
58
64
  }
@@ -119,6 +119,11 @@ export interface ResolveStagePendingPromptOptions {
119
119
  readonly answerSource?: StagePromptAnswerSource;
120
120
  }
121
121
 
122
+ export interface RecordStagePromptAnswerOptions {
123
+ /** Identifies who answered the prompt so notification code can avoid echoing workflow-tool answers. */
124
+ readonly answerSource?: StagePromptAnswerSource;
125
+ }
126
+
122
127
  export interface Store {
123
128
  runs(): readonly RunSnapshot[];
124
129
  notices(): readonly WorkflowNotice[];
@@ -206,6 +211,19 @@ export interface Store {
206
211
  ): boolean;
207
212
  /** Wait for a stage/node-scoped HIL prompt to resolve. */
208
213
  awaitStagePendingPrompt(runId: string, stageId: string, promptId: string): Promise<unknown>;
214
+ /**
215
+ * Record a live-only prompt answer for prompt-node UIs that do not use
216
+ * `stage.pendingPrompt` (notably arbitrary `ctx.ui.custom<T>` widgets).
217
+ * The raw value stays in the private answer ledger and is never serialized
218
+ * into snapshots or persistence.
219
+ */
220
+ recordStagePromptAnswer(
221
+ runId: string,
222
+ stageId: string,
223
+ prompt: PendingPrompt,
224
+ response: unknown,
225
+ options?: RecordStagePromptAnswerOptions,
226
+ ): boolean;
209
227
  /**
210
228
  * Record a live-only draft for an active stage-local input/editor prompt.
211
229
  * Draft text may contain secrets and must never be copied into snapshots,
@@ -774,6 +792,39 @@ export function createStore(): Store {
774
792
  });
775
793
  },
776
794
 
795
+ recordStagePromptAnswer(
796
+ runId: string,
797
+ stageId: string,
798
+ prompt: PendingPrompt,
799
+ response: unknown,
800
+ options: RecordStagePromptAnswerOptions = {},
801
+ ): boolean {
802
+ const run = findRun(runId);
803
+ if (!run) return false;
804
+ if (TERMINAL_STATUSES.has(run.status)) return false;
805
+ const stage = findStage(run, stageId);
806
+ if (!stage) return false;
807
+ if (isTerminalStageStatus(stage.status)) return false;
808
+ _stagePromptAnswers.set(stagePromptAnswerKey(runId, stageId), {
809
+ runId,
810
+ stageId,
811
+ promptId: prompt.id,
812
+ kind: prompt.kind,
813
+ value: response,
814
+ answeredAt: Date.now(),
815
+ ...(options.answerSource !== undefined ? { answerSource: options.answerSource } : {}),
816
+ });
817
+ if (stage.promptFootprint === undefined) stage.promptFootprint = { ...prompt };
818
+ stage.promptAnswerState = "available";
819
+ if (stage.status === "awaiting_input") {
820
+ stage.status = "running";
821
+ delete stage.awaitingInputSince;
822
+ }
823
+ _version++;
824
+ notify();
825
+ return true;
826
+ },
827
+
777
828
  recordStagePromptDraft(runId: string, stageId: string, promptId: string, text: string): boolean {
778
829
  if (stageHasActiveTextPrompt(runId, stageId, promptId) === undefined) return false;
779
830
  _stagePromptDrafts.set(stagePromptDraftKey(runId, stageId, promptId), text);
@@ -116,14 +116,24 @@ export interface WorkflowChildResult<TOutputs extends WorkflowOutputValues = Wor
116
116
  * Each primitive suspends the current stage until the user responds.
117
117
  * Mirrors pi ctx.ui.input / confirm / select / editor methods.
118
118
  */
119
- export type WorkflowUIContext = AuthoringContract.WorkflowUIContext;
119
+ export type WorkflowCustomUiComponent = AuthoringContract.WorkflowCustomUiComponent;
120
+ export type WorkflowCustomUiTui = AuthoringContract.WorkflowCustomUiTui;
121
+ export type WorkflowCustomUiTheme = AuthoringContract.WorkflowCustomUiTheme;
122
+ export type WorkflowCustomUiKeybindings = AuthoringContract.WorkflowCustomUiKeybindings;
123
+ export type WorkflowCustomUiOverlayOptions = AuthoringContract.WorkflowCustomUiOverlayOptions;
124
+ export type WorkflowCustomUiOverlayHandle = AuthoringContract.WorkflowCustomUiOverlayHandle;
125
+ export type WorkflowCustomUiFactory<T> = AuthoringContract.WorkflowCustomUiFactory<T>;
126
+ export type WorkflowCustomUiOptions = AuthoringContract.WorkflowCustomUiOptions;
127
+
128
+ export interface WorkflowUIContext extends AuthoringContract.WorkflowUIContext {}
120
129
 
121
130
  /**
122
131
  * Adapter supplied by the pi runtime (or test harness) to back the HIL
123
- * primitives. Must implement the same surface as WorkflowUIContext so that
124
- * the executor can delegate directly.
132
+ * primitives. The custom-widget method is optional for compatibility with
133
+ * existing primitive-only adapters; the executor normalizes a missing custom
134
+ * method to the same unavailable-UI rejection used in headless mode.
125
135
  */
126
- export type WorkflowUIAdapter = AuthoringContract.WorkflowUIAdapter;
136
+ export interface WorkflowUIAdapter extends AuthoringContract.WorkflowUIAdapter {}
127
137
 
128
138
  // ---------------------------------------------------------------------------
129
139
  // StageOptions — per-stage configuration + pi SDK session options
@@ -357,7 +357,10 @@ export class GraphView implements Component {
357
357
  ? expandWorkflowGraph(this.currentSnapshot, run.id)
358
358
  : { stages: [], targets: new Map() };
359
359
  const stages = [...this.expandedGraph.stages];
360
- const hasStagePrompt = stages.some((stage) => stage.pendingPrompt !== undefined);
360
+ const hasStagePrompt = stages.some((stage) =>
361
+ stage.pendingPrompt !== undefined ||
362
+ (stage.status === "awaiting_input" && stage.promptFootprint?.kind === "custom")
363
+ );
361
364
  if (!hasStagePrompt) return stages;
362
365
  return stages.filter((stage) => {
363
366
  // Prompt-node injection can leave unstarted author stages in the store