@bastani/atomic 0.8.19 → 0.8.20

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 (103) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/CHANGELOG.md +10 -0
  4. package/dist/builtin/mcp/package.json +2 -2
  5. package/dist/builtin/subagents/CHANGELOG.md +17 -2
  6. package/dist/builtin/subagents/agents/code-simplifier.md +1 -1
  7. package/dist/builtin/subagents/agents/codebase-analyzer.md +1 -1
  8. package/dist/builtin/subagents/agents/codebase-online-researcher.md +1 -1
  9. package/dist/builtin/subagents/agents/codebase-research-analyzer.md +1 -1
  10. package/dist/builtin/subagents/agents/debugger.md +1 -1
  11. package/dist/builtin/subagents/package.json +1 -1
  12. package/dist/builtin/subagents/skills/subagent/SKILL.md +12 -12
  13. package/dist/builtin/subagents/src/agents/agent-management.ts +16 -11
  14. package/dist/builtin/subagents/src/agents/skills.ts +13 -1
  15. package/dist/builtin/subagents/src/extension/index.ts +14 -3
  16. package/dist/builtin/subagents/src/runs/background/async-execution.ts +8 -0
  17. package/dist/builtin/subagents/src/runs/background/run-status.ts +2 -3
  18. package/dist/builtin/subagents/src/runs/background/subagent-runner.ts +11 -1
  19. package/dist/builtin/subagents/src/runs/foreground/chain-clarify.ts +2 -2
  20. package/dist/builtin/subagents/src/runs/foreground/chain-execution.ts +31 -23
  21. package/dist/builtin/subagents/src/runs/foreground/execution.ts +13 -7
  22. package/dist/builtin/subagents/src/runs/foreground/subagent-executor.ts +160 -93
  23. package/dist/builtin/subagents/src/runs/shared/parallel-utils.ts +1 -0
  24. package/dist/builtin/subagents/src/runs/shared/run-history.ts +1 -1
  25. package/dist/builtin/subagents/src/shared/settings.ts +1 -0
  26. package/dist/builtin/subagents/src/shared/types.ts +78 -4
  27. package/dist/builtin/subagents/src/tui/render.ts +203 -19
  28. package/dist/builtin/web-access/CHANGELOG.md +10 -0
  29. package/dist/builtin/web-access/package.json +2 -2
  30. package/dist/builtin/workflows/CHANGELOG.md +25 -0
  31. package/dist/builtin/workflows/README.md +22 -3
  32. package/dist/builtin/workflows/builtin/deep-research-codebase.ts +1 -1
  33. package/dist/builtin/workflows/builtin/open-claude-design.ts +12 -4
  34. package/dist/builtin/workflows/builtin/ralph.ts +2 -2
  35. package/dist/builtin/workflows/package.json +1 -1
  36. package/dist/builtin/workflows/src/extension/config-loader.ts +68 -0
  37. package/dist/builtin/workflows/src/extension/index.ts +246 -55
  38. package/dist/builtin/workflows/src/extension/lifecycle-notifications.ts +372 -0
  39. package/dist/builtin/workflows/src/extension/render-call.ts +1 -1
  40. package/dist/builtin/workflows/src/extension/wiring.ts +32 -3
  41. package/dist/builtin/workflows/src/runs/background/status.ts +14 -74
  42. package/dist/builtin/workflows/src/shared/persistence-restore.ts +5 -3
  43. package/dist/builtin/workflows/src/tui/chat-surface-message.ts +3 -13
  44. package/dist/builtin/workflows/src/tui/inline-form-overlay.ts +2 -10
  45. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +5 -5
  46. package/dist/builtin/workflows/src/tui/session-confirm.ts +6 -7
  47. package/dist/builtin/workflows/src/tui/session-picker.ts +18 -14
  48. package/dist/builtin/workflows/src/tui/status-list.ts +2 -2
  49. package/dist/builtin/workflows/src/tui/store-widget-installer.ts +125 -30
  50. package/dist/config.d.ts +1 -0
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/config.js +1 -0
  53. package/dist/config.js.map +1 -1
  54. package/dist/core/agent-session.d.ts +4 -1
  55. package/dist/core/agent-session.d.ts.map +1 -1
  56. package/dist/core/agent-session.js +2 -1
  57. package/dist/core/agent-session.js.map +1 -1
  58. package/dist/core/atomic-guide-command.d.ts.map +1 -1
  59. package/dist/core/atomic-guide-command.js +3 -2
  60. package/dist/core/atomic-guide-command.js.map +1 -1
  61. package/dist/core/extensions/index.d.ts +1 -1
  62. package/dist/core/extensions/index.d.ts.map +1 -1
  63. package/dist/core/extensions/index.js.map +1 -1
  64. package/dist/core/extensions/runner.d.ts +3 -2
  65. package/dist/core/extensions/runner.d.ts.map +1 -1
  66. package/dist/core/extensions/runner.js +6 -1
  67. package/dist/core/extensions/runner.js.map +1 -1
  68. package/dist/core/extensions/types.d.ts +13 -0
  69. package/dist/core/extensions/types.d.ts.map +1 -1
  70. package/dist/core/extensions/types.js.map +1 -1
  71. package/dist/core/model-resolver.d.ts.map +1 -1
  72. package/dist/core/model-resolver.js +63 -17
  73. package/dist/core/model-resolver.js.map +1 -1
  74. package/dist/core/output-guard.d.ts.map +1 -1
  75. package/dist/core/output-guard.js +29 -0
  76. package/dist/core/output-guard.js.map +1 -1
  77. package/dist/core/sdk.d.ts +3 -1
  78. package/dist/core/sdk.d.ts.map +1 -1
  79. package/dist/core/sdk.js +1 -0
  80. package/dist/core/sdk.js.map +1 -1
  81. package/dist/core/system-prompt.d.ts.map +1 -1
  82. package/dist/core/system-prompt.js +1 -1
  83. package/dist/core/system-prompt.js.map +1 -1
  84. package/dist/index.d.ts +2 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  89. package/dist/modes/interactive/interactive-mode.js +46 -13
  90. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  91. package/dist/utils/pi-user-agent.d.ts.map +1 -1
  92. package/dist/utils/pi-user-agent.js +2 -1
  93. package/dist/utils/pi-user-agent.js.map +1 -1
  94. package/dist/utils/syntax-highlight.d.ts.map +1 -1
  95. package/dist/utils/syntax-highlight.js +1 -1
  96. package/dist/utils/syntax-highlight.js.map +1 -1
  97. package/dist/utils/tools-manager.d.ts.map +1 -1
  98. package/dist/utils/tools-manager.js +3 -5
  99. package/dist/utils/tools-manager.js.map +1 -1
  100. package/docs/models.md +52 -52
  101. package/docs/quickstart.md +2 -2
  102. package/docs/workflows.md +22 -5
  103. package/package.json +9 -9
@@ -0,0 +1,372 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ PiMessageRenderComponent,
4
+ PiMessageRendererResult,
5
+ } from "./index.js";
6
+ import type { Store } from "../shared/store.js";
7
+ import type {
8
+ PendingPrompt,
9
+ PromptKind,
10
+ RunSnapshot,
11
+ RunStatus,
12
+ StageSnapshot,
13
+ StageStatus,
14
+ StoreSnapshot,
15
+ } from "../shared/store-types.js";
16
+ import { wrapPlainText } from "../tui/text-helpers.js";
17
+
18
+ export const LIFECYCLE_NOTICE_CUSTOM_TYPE = "workflows:lifecycle-notice";
19
+ export const LIFECYCLE_NOTICE_SNIPPET_LIMIT = 240;
20
+
21
+ export type WorkflowLifecycleNoticeKind = "completed" | "failed" | "awaiting_input";
22
+
23
+ export const WORKFLOW_LIFECYCLE_NOTICE_KINDS = [
24
+ "completed",
25
+ "failed",
26
+ "awaiting_input",
27
+ ] as const satisfies readonly WorkflowLifecycleNoticeKind[];
28
+
29
+ export interface WorkflowLifecycleNotificationConfig {
30
+ readonly enabled: boolean;
31
+ readonly notifyOn: readonly WorkflowLifecycleNoticeKind[];
32
+ }
33
+
34
+ export interface WorkflowLifecycleNoticeDetails {
35
+ readonly kind: WorkflowLifecycleNoticeKind;
36
+ readonly scope: "run" | "stage";
37
+ readonly runId: string;
38
+ readonly workflowName: string;
39
+ readonly status: RunStatus | StageStatus;
40
+ readonly stageId?: string;
41
+ readonly stageName?: string;
42
+ readonly promptId?: string;
43
+ readonly promptKind?: PromptKind;
44
+ readonly promptMessage?: string;
45
+ readonly error?: string;
46
+ readonly failedStageId?: string;
47
+ readonly durationMs?: number;
48
+ readonly createdAt: number;
49
+ }
50
+
51
+ export interface WorkflowLifecycleNotificationState {
52
+ readonly deliveredTerminalRuns: Set<string>;
53
+ readonly deliveredInputPrompts: Set<string>;
54
+ suppressionDepth: number;
55
+ }
56
+
57
+ export interface WorkflowLifecycleNotificationOptions {
58
+ readonly store: Store;
59
+ readonly sendMessage?: ExtensionAPI["sendMessage"];
60
+ readonly registerMessageRenderer?: ExtensionAPI["registerMessageRenderer"];
61
+ readonly rendererHost?: object;
62
+ readonly config: WorkflowLifecycleNotificationConfig;
63
+ readonly state?: WorkflowLifecycleNotificationState;
64
+ readonly seedExisting?: boolean;
65
+ }
66
+
67
+ type RawRenderer = (payload: unknown) => PiMessageRendererResult;
68
+
69
+ // Process-lifetime registration dedupe: extension hosts are object identities
70
+ // and may be garbage-collected, but renderer registrations are not unregistered.
71
+ const rendererRegisteredHosts = new WeakSet<object>();
72
+
73
+ export function createWorkflowLifecycleNotificationState(): WorkflowLifecycleNotificationState {
74
+ return {
75
+ deliveredTerminalRuns: new Set<string>(),
76
+ deliveredInputPrompts: new Set<string>(),
77
+ suppressionDepth: 0,
78
+ };
79
+ }
80
+
81
+ export function resetWorkflowLifecycleNotificationState(
82
+ state: WorkflowLifecycleNotificationState,
83
+ ): void {
84
+ state.deliveredTerminalRuns.clear();
85
+ state.deliveredInputPrompts.clear();
86
+ state.suppressionDepth = 0;
87
+ }
88
+
89
+ export function seedWorkflowLifecycleNotificationState(
90
+ state: WorkflowLifecycleNotificationState,
91
+ snapshot: StoreSnapshot,
92
+ ): void {
93
+ for (const run of snapshot.runs) {
94
+ if ((run.status === "completed" || run.status === "failed") && run.endedAt !== undefined) {
95
+ state.deliveredTerminalRuns.add(terminalRunKey(run.status, run.id));
96
+ }
97
+ if (run.pendingPrompt !== undefined) {
98
+ state.deliveredInputPrompts.add(runAwaitingInputKey(run.id, run.pendingPrompt));
99
+ }
100
+ for (const stage of run.stages) {
101
+ if (stage.status === "awaiting_input") {
102
+ state.deliveredInputPrompts.add(awaitingInputKey(run.id, stage));
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Suppress lifecycle notice emission while still observing snapshot changes and
110
+ * marking matching lifecycle states as delivered. This is intended for restore
111
+ * or replay paths where historical workflow states should seed dedupe state
112
+ * without notifying the current chat; it is not a generic temporary mute that
113
+ * should emit the same notices later.
114
+ */
115
+ export function withWorkflowLifecycleNotificationsSuppressed<T>(
116
+ state: WorkflowLifecycleNotificationState,
117
+ fn: () => T,
118
+ ): T {
119
+ state.suppressionDepth += 1;
120
+ try {
121
+ return fn();
122
+ } finally {
123
+ state.suppressionDepth -= 1;
124
+ }
125
+ }
126
+
127
+ export function installWorkflowLifecycleNotifications(
128
+ options: WorkflowLifecycleNotificationOptions,
129
+ ): () => void {
130
+ registerLifecycleNoticeRenderer(options);
131
+
132
+ if (!options.config.enabled) return () => undefined;
133
+ const send = options.sendMessage;
134
+ if (typeof send !== "function") return () => undefined;
135
+
136
+ const notifyOn = new Set<WorkflowLifecycleNoticeKind>(options.config.notifyOn);
137
+ const state = options.state ?? createWorkflowLifecycleNotificationState();
138
+ if (options.seedExisting !== false) {
139
+ seedWorkflowLifecycleNotificationState(state, options.store.snapshot());
140
+ }
141
+
142
+ const emit = (details: WorkflowLifecycleNoticeDetails): void => {
143
+ const content = formatWorkflowLifecycleNoticeText(details);
144
+ try {
145
+ // Store subscribers are notified in a tight loop. A lifecycle notice
146
+ // failure must never abort sibling subscribers such as status writers.
147
+ void Promise.resolve(
148
+ send(
149
+ {
150
+ customType: LIFECYCLE_NOTICE_CUSTOM_TYPE,
151
+ content,
152
+ display: true,
153
+ details,
154
+ },
155
+ { triggerTurn: true, deliverAs: "steer" },
156
+ ),
157
+ ).catch((error: unknown) => warnLifecycleSendFailure(error));
158
+ } catch (error) {
159
+ warnLifecycleSendFailure(error);
160
+ // Best-effort notification only; keep store delivery isolated.
161
+ }
162
+ };
163
+
164
+ const emitTerminalNoticeOnce = (
165
+ run: RunSnapshot,
166
+ kind: "completed" | "failed",
167
+ ): void => {
168
+ if (run.status !== kind || run.endedAt === undefined || !notifyOn.has(kind)) {
169
+ return;
170
+ }
171
+
172
+ const key = terminalRunKey(kind, run.id);
173
+ if (state.deliveredTerminalRuns.has(key)) return;
174
+
175
+ state.deliveredTerminalRuns.add(key);
176
+ if (state.suppressionDepth > 0) return;
177
+ emit(makeTerminalNotice(run, kind));
178
+ };
179
+
180
+ const emitStageAwaitingInputNoticeOnce = (
181
+ run: RunSnapshot,
182
+ stage: StageSnapshot,
183
+ ): void => {
184
+ if (stage.status !== "awaiting_input") return;
185
+
186
+ const key = awaitingInputKey(run.id, stage);
187
+ if (state.deliveredInputPrompts.has(key)) return;
188
+
189
+ state.deliveredInputPrompts.add(key);
190
+ if (state.suppressionDepth > 0) return;
191
+ emit(makeStageAwaitingInputNotice(run, stage));
192
+ };
193
+
194
+ const emitRunAwaitingInputNoticeOnce = (run: RunSnapshot): void => {
195
+ if (run.pendingPrompt === undefined) return;
196
+
197
+ const key = runAwaitingInputKey(run.id, run.pendingPrompt);
198
+ if (state.deliveredInputPrompts.has(key)) return;
199
+
200
+ state.deliveredInputPrompts.add(key);
201
+ if (state.suppressionDepth > 0) return;
202
+ emit(makeRunAwaitingInputNotice(run, run.pendingPrompt));
203
+ };
204
+
205
+ const inspect = (snapshot: StoreSnapshot): void => {
206
+ for (const run of snapshot.runs) {
207
+ emitTerminalNoticeOnce(run, "completed");
208
+ emitTerminalNoticeOnce(run, "failed");
209
+
210
+ if (!notifyOn.has("awaiting_input")) continue;
211
+ emitRunAwaitingInputNoticeOnce(run);
212
+ for (const stage of run.stages) {
213
+ emitStageAwaitingInputNoticeOnce(run, stage);
214
+ }
215
+ }
216
+ };
217
+
218
+ return options.store.subscribe(inspect);
219
+ }
220
+
221
+ export function registerLifecycleNoticeRenderer(
222
+ options: Pick<WorkflowLifecycleNotificationOptions, "registerMessageRenderer" | "rendererHost">,
223
+ ): void {
224
+ const register = options.registerMessageRenderer;
225
+ if (typeof register !== "function") return;
226
+
227
+ const host = options.rendererHost ?? register;
228
+ if (rendererRegisteredHosts.has(host)) return;
229
+
230
+ const renderer: RawRenderer = (raw) => {
231
+ const message = raw as { details?: WorkflowLifecycleNoticeDetails };
232
+ if (!message.details) return undefined;
233
+ return makeNoticeComponent(message.details);
234
+ };
235
+
236
+ register(LIFECYCLE_NOTICE_CUSTOM_TYPE, renderer);
237
+ rendererRegisteredHosts.add(host);
238
+ }
239
+
240
+ export function formatWorkflowLifecycleNoticeText(details: WorkflowLifecycleNoticeDetails): string {
241
+ const workflowName = escapeQuotedText(details.workflowName);
242
+ if (details.kind === "completed") {
243
+ return `✅ Workflow "${workflowName}" completed (run ${details.runId}). Inspect: /workflow status ${details.runId}`;
244
+ }
245
+ if (details.kind === "failed") {
246
+ const stage = details.stageName ?? details.failedStageId;
247
+ const stageText = stage ? `, stage ${stage}` : "";
248
+ const errorText = details.error ? `: ${details.error}` : "";
249
+ return `❌ Workflow "${workflowName}" failed (run ${details.runId}${stageText})${errorText}. Inspect: /workflow status ${details.runId}`;
250
+ }
251
+ const prompt = details.promptMessage ? ` Prompt: ${details.promptMessage}` : "";
252
+ if (details.scope === "run") {
253
+ return `❓ Workflow "${workflowName}" needs input (run ${details.runId}).${prompt} Respond: /workflow connect ${details.runId} to answer this run-level prompt.`;
254
+ }
255
+ const stage = details.stageName ?? details.stageId ?? "unknown";
256
+ const responseHint = details.stageId && details.promptId
257
+ ? `/workflow connect ${details.runId} or workflow({ action: "send", runId: ${jsonString(details.runId)}, stageId: ${jsonString(details.stageId)}, promptId: ${jsonString(details.promptId)}, response: ... })`
258
+ : `/workflow connect ${details.runId}`;
259
+ return `❓ Workflow "${workflowName}" needs input (run ${details.runId}, stage ${stage}).${prompt} Respond: ${responseHint}.`;
260
+ }
261
+
262
+ function makeTerminalNotice(
263
+ run: RunSnapshot,
264
+ kind: "completed" | "failed",
265
+ ): WorkflowLifecycleNoticeDetails {
266
+ const failedStage = run.failedStageId
267
+ ? run.stages.find((stage) => stage.id === run.failedStageId)
268
+ : undefined;
269
+ return {
270
+ kind,
271
+ scope: "run",
272
+ runId: run.id,
273
+ workflowName: run.name,
274
+ status: run.status,
275
+ ...(run.error ? { error: truncateSnippet(run.error) } : {}),
276
+ ...(run.failedStageId ? { failedStageId: run.failedStageId } : {}),
277
+ ...(failedStage ? { stageId: failedStage.id, stageName: failedStage.name } : {}),
278
+ ...(run.durationMs !== undefined ? { durationMs: run.durationMs } : {}),
279
+ // Normal store paths stamp endedAt; Date.now() is defensive for malformed restored snapshots.
280
+ createdAt: run.endedAt ?? Date.now(),
281
+ };
282
+ }
283
+
284
+ function makeStageAwaitingInputNotice(run: RunSnapshot, stage: StageSnapshot): WorkflowLifecycleNoticeDetails {
285
+ const prompt = stage.pendingPrompt;
286
+ return {
287
+ kind: "awaiting_input",
288
+ scope: "stage",
289
+ runId: run.id,
290
+ workflowName: run.name,
291
+ status: stage.status,
292
+ stageId: stage.id,
293
+ stageName: stage.name,
294
+ ...(prompt ? promptFields(prompt) : {}),
295
+ // Normal store paths stamp awaitingInputSince; Date.now() is defensive for malformed restored snapshots.
296
+ createdAt: prompt?.createdAt ?? stage.awaitingInputSince ?? Date.now(),
297
+ };
298
+ }
299
+
300
+ function makeRunAwaitingInputNotice(run: RunSnapshot, prompt: PendingPrompt): WorkflowLifecycleNoticeDetails {
301
+ return {
302
+ kind: "awaiting_input",
303
+ scope: "run",
304
+ runId: run.id,
305
+ workflowName: run.name,
306
+ status: run.status,
307
+ ...promptFields(prompt),
308
+ createdAt: prompt.createdAt,
309
+ };
310
+ }
311
+
312
+ function warnLifecycleSendFailure(error: unknown): void {
313
+ if (process.env.ATOMIC_WORKFLOW_DEBUG !== "1") return;
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ console.warn("[workflows] workflow lifecycle notice send failed", message);
316
+ }
317
+
318
+ function escapeQuotedText(value: string): string {
319
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
320
+ }
321
+
322
+ function jsonString(value: string): string {
323
+ return JSON.stringify(value);
324
+ }
325
+
326
+ function terminalRunKey(kind: "completed" | "failed", runId: string): string {
327
+ return `${kind}:${runId}`;
328
+ }
329
+
330
+ function promptFields(
331
+ prompt: PendingPrompt,
332
+ ): Pick<WorkflowLifecycleNoticeDetails, "promptId" | "promptKind" | "promptMessage"> {
333
+ return {
334
+ promptId: prompt.id,
335
+ promptKind: prompt.kind,
336
+ promptMessage: truncateSnippet(prompt.message),
337
+ };
338
+ }
339
+
340
+ function awaitingInputKey(runId: string, stage: StageSnapshot): string {
341
+ const promptId = stage.pendingPrompt?.id;
342
+ if (promptId) return `awaiting_input:${runId}:stage:${stage.id}:${promptId}`;
343
+ return `awaiting_input:${runId}:stage:${stage.id}:${stage.awaitingInputSince ?? "active"}`;
344
+ }
345
+
346
+ function runAwaitingInputKey(runId: string, prompt: PendingPrompt): string {
347
+ return `awaiting_input:${runId}:run:${prompt.id}`;
348
+ }
349
+
350
+ function truncateSnippet(value: string): string {
351
+ const normalized = value.replace(/\s+/g, " ").trim();
352
+ if (normalized.length <= LIFECYCLE_NOTICE_SNIPPET_LIMIT) return normalized;
353
+ return `${normalized.slice(0, LIFECYCLE_NOTICE_SNIPPET_LIMIT - 1)}…`;
354
+ }
355
+
356
+ function makeNoticeComponent(details: WorkflowLifecycleNoticeDetails): PiMessageRenderComponent {
357
+ const text = formatWorkflowLifecycleNoticeText(details);
358
+ return {
359
+ render(width: number): string[] {
360
+ // Wrap to the render width so a long run id / workflow name never emits a
361
+ // line wider than the terminal. pi-tui hard-throws ("Rendered line N
362
+ // exceeds terminal width") on any over-wide rendered line, which would
363
+ // crash the whole TUI on narrow terminals or after a resize (#1109).
364
+ // `wrapPlainText` hard-breaks long unbreakable tokens (e.g. UUIDs), so
365
+ // every returned line is guaranteed to fit within `width`.
366
+ return wrapPlainText(text, width);
367
+ },
368
+ invalidate() {
369
+ /* stored lifecycle notices are immutable */
370
+ },
371
+ };
372
+ }
@@ -67,7 +67,7 @@ export function renderCall(args: WorkflowToolArgs, opts: RenderCallOpts = {}): s
67
67
  line = "workflow: list registered workflows";
68
68
  break;
69
69
  case "status":
70
- line = "workflow: list in-flight runs";
70
+ line = "workflow: list retained runs";
71
71
  break;
72
72
  case "inputs":
73
73
  line = name === undefined
@@ -22,8 +22,7 @@
22
22
  */
23
23
 
24
24
  import { basename } from "node:path";
25
- import type { CreateAgentSessionOptions } from "@bastani/atomic";
26
- import type { ChatMessageRenderOptions } from "@bastani/atomic";
25
+ import type { ChatMessageRenderOptions, CreateAgentSessionOptions } from "@bastani/atomic";
27
26
  import type { StageAdapters, StageSessionRuntime } from "../runs/foreground/stage-runner.js";
28
27
  import type { StageExecutionMeta, StageOptions } from "../shared/types.js";
29
28
  import { stageUiBroker, type StageUiBroker } from "../shared/stage-ui-broker.js";
@@ -237,6 +236,33 @@ function stripWorkflowOnlyOptions(options: (StageOptions | CreateAgentSessionOpt
237
236
  return sessionOptions as CreateAgentSessionOptions;
238
237
  }
239
238
 
239
+ function makeWorkflowStageOrchestrationContext(meta: StageExecutionMeta): NonNullable<CreateAgentSessionOptions["orchestrationContext"]> {
240
+ return {
241
+ kind: "workflow-stage",
242
+ workflowRunId: meta.runId,
243
+ workflowStageId: meta.stageId,
244
+ workflowStageName: meta.stageName,
245
+ constraints: {
246
+ disableWorkflowTool: true,
247
+ maxSubagentDepth: 1,
248
+ },
249
+ };
250
+ }
251
+
252
+ function withWorkflowStageSessionOptions(
253
+ options: CreateAgentSessionOptions,
254
+ meta: StageExecutionMeta | undefined,
255
+ ): CreateAgentSessionOptions {
256
+ // Workflow stage sessions should never see the workflow tool, even when older
257
+ // meta-less callers cannot receive the richer runtime orchestration context.
258
+ const excludedTools = Array.from(new Set([...(options.excludedTools ?? []), "workflow"]));
259
+ return {
260
+ ...options,
261
+ excludedTools,
262
+ ...(meta ? { orchestrationContext: makeWorkflowStageOrchestrationContext(meta) } : {}),
263
+ };
264
+ }
265
+
240
266
  function makeStageExtensionUiContext(
241
267
  ui: PiUISurface,
242
268
  meta: StageExecutionMeta | undefined,
@@ -325,7 +351,10 @@ export function buildRuntimeAdapters(
325
351
  // extensions, tools, prompts, and skills as the parent chat. Callers
326
352
  // can still opt into a custom resource set by passing `resourceLoader`
327
353
  // through `stage(name, options)`.
328
- const sessionOptions: CreateAgentSessionOptions = stripWorkflowOnlyOptions(stageOptions) ?? {};
354
+ const sessionOptions = withWorkflowStageSessionOptions(
355
+ stripWorkflowOnlyOptions(stageOptions) ?? {},
356
+ meta,
357
+ );
329
358
  const result = await createSession(sessionOptions);
330
359
  const bindable = result.session as BindableStageSession;
331
360
  if ((pi.ui !== undefined || meta !== undefined) && typeof bindable.bindExtensions === "function") {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Status / kill / resume control helpers for in-flight workflow runs.
2
+ * Status / kill / resume helpers for retained workflow runs and live controls.
3
3
  *
4
4
  * These helpers operate against the singleton store and are consumed by:
5
5
  * - The `workflow` tool execute handler (action: "status" | "kill" | "resume")
@@ -34,10 +34,6 @@ export type KillResult =
34
34
  | { ok: true; runId: string; previousStatus: RunStatus }
35
35
  | { ok: false; runId: string; reason: "not_found" | "already_ended" };
36
36
 
37
- export type DestroyRunResult =
38
- | { ok: true; runId: string; previousStatus: RunStatus; wasInFlight: boolean }
39
- | { ok: false; runId: string; reason: "not_found" };
40
-
41
37
  export type ResumeResult =
42
38
  | {
43
39
  ok: true;
@@ -95,27 +91,22 @@ export type InspectRunResult =
95
91
  // ---------------------------------------------------------------------------
96
92
 
97
93
  /**
98
- * Returns a summary of all in-flight (not-yet-ended) runs in the store.
99
- * If `all` is true, returns completed/failed runs too.
94
+ * Returns a summary of all retained runs in the current store/session.
95
+ *
96
+ * Terminal snapshots are retained for inspection and are visible by default;
97
+ * the legacy `all` option is accepted as a compatibility no-op.
100
98
  */
101
99
  export function statusRuns(opts?: { all?: boolean; store?: Store }): RunStatusEntry[] {
102
100
  const activeStore = opts?.store ?? defaultStore;
103
- const runs = activeStore.runs();
104
- const result: RunStatusEntry[] = [];
105
-
106
- for (const run of runs) {
107
- if (!opts?.all && run.endedAt !== undefined) continue;
108
- result.push({
109
- runId: run.id,
110
- name: run.name,
111
- status: run.status,
112
- startedAt: run.startedAt,
113
- durationMs: run.durationMs,
114
- stageCount: run.stages.length,
115
- });
116
- }
117
101
 
118
- return result;
102
+ return activeStore.runs().map((run) => ({
103
+ runId: run.id,
104
+ name: run.name,
105
+ status: run.status,
106
+ startedAt: run.startedAt,
107
+ durationMs: run.durationMs,
108
+ stageCount: run.stages.length,
109
+ }));
119
110
  }
120
111
 
121
112
  // ---------------------------------------------------------------------------
@@ -179,57 +170,6 @@ export function killAllRuns(opts?: {
179
170
  );
180
171
  }
181
172
 
182
- // ---------------------------------------------------------------------------
183
- // destroyRun
184
- // ---------------------------------------------------------------------------
185
-
186
- /**
187
- * Destructively kills a workflow run and removes it from live history/status.
188
- *
189
- * In-flight runs are aborted and persisted with a terminal `killed` entry so
190
- * session restore will not resurrect them. Ended runs are simply removed from
191
- * the live store without appending a duplicate terminal event.
192
- */
193
- export function destroyRun(
194
- runId: string,
195
- opts?: { store?: Store; cancellation?: CancellationRegistry; persistence?: WorkflowPersistencePort },
196
- ): DestroyRunResult {
197
- const activeStore = opts?.store ?? defaultStore;
198
- const run = activeStore.runs().find((r) => r.id === runId);
199
-
200
- if (!run) {
201
- return { ok: false, runId, reason: "not_found" };
202
- }
203
-
204
- const previousStatus = run.status;
205
- const wasInFlight = run.endedAt === undefined;
206
-
207
- if (wasInFlight) {
208
- opts?.cancellation?.abort(runId, "workflow killed");
209
- if (opts?.persistence) {
210
- appendRunEnd(opts.persistence, { runId, status: "killed", ts: Date.now() });
211
- }
212
- }
213
-
214
- activeStore.removeRun(runId);
215
- return { ok: true, runId, previousStatus, wasInFlight };
216
- }
217
-
218
- /**
219
- * Destructively kills and removes all in-flight runs.
220
- */
221
- export function destroyAllRuns(opts?: {
222
- store?: Store;
223
- cancellation?: CancellationRegistry;
224
- persistence?: WorkflowPersistencePort;
225
- }): DestroyRunResult[] {
226
- const activeStore = opts?.store ?? defaultStore;
227
- const inFlight = activeStore.runs().filter((r) => r.endedAt === undefined);
228
- return inFlight.map((r) =>
229
- destroyRun(r.id, { store: activeStore, cancellation: opts?.cancellation, persistence: opts?.persistence }),
230
- );
231
- }
232
-
233
173
  // ---------------------------------------------------------------------------
234
174
  // resumeRun
235
175
  // ---------------------------------------------------------------------------
@@ -408,7 +348,7 @@ export function pauseAllRuns(opts?: {
408
348
 
409
349
  /**
410
350
  * Interrupt a run in a resumable way by pausing live stage handles when
411
- * available. Unlike `destroyRun`, this never aborts the workflow controller and
351
+ * available. This never aborts the workflow controller and
412
352
  * never removes the run from status/history.
413
353
  */
414
354
  export function interruptRun(
@@ -163,7 +163,7 @@ export function restoreOnSessionStart(
163
163
 
164
164
  const entries = getEntries.call(sessionManager);
165
165
  const sessionEntries = entries as readonly SessionEntry[];
166
- restoreEndedFailedRuns(sessionEntries, store);
166
+ restoreTerminalRuns(sessionEntries, store);
167
167
  const inFlight = scanInFlightRuns(sessionEntries);
168
168
  if (inFlight.length === 0) return;
169
169
 
@@ -300,7 +300,7 @@ function restoreStageStatus(status: unknown): StageStatus {
300
300
  }
301
301
  }
302
302
 
303
- function restoreEndedFailedRuns(entries: readonly SessionEntry[], store: Store): void {
303
+ function restoreTerminalRuns(entries: readonly SessionEntry[], store: Store): void {
304
304
  const started = new Map<string, { readonly name: string; readonly inputs: Readonly<Record<string, unknown>>; readonly startTs: number }>();
305
305
  const ended = new Map<string, Record<string, unknown>>();
306
306
 
@@ -334,6 +334,7 @@ function restoreEndedFailedRuns(entries: readonly SessionEntry[], store: Store):
334
334
 
335
335
  const runMeta = findRunStartMetadata(entries, runId);
336
336
  const stages = _buildStageSnapshots(entries, runId);
337
+ if (status === "completed" && stages.some((stage) => stage.status !== "completed")) continue;
337
338
  store.recordRunStart({
338
339
  id: runId,
339
340
  name: start.name,
@@ -365,8 +366,9 @@ function restoreEndedFailedRuns(entries: readonly SessionEntry[], store: Store):
365
366
  }
366
367
  }
367
368
 
368
- function restoreTerminalRunStatus(status: unknown): "failed" | "killed" | undefined {
369
+ function restoreTerminalRunStatus(status: unknown): "completed" | "failed" | "killed" | undefined {
369
370
  switch (status) {
371
+ case "completed":
370
372
  case "failed":
371
373
  case "killed":
372
374
  return status;
@@ -79,12 +79,11 @@ export interface DetailPayload {
79
79
  detail: RunDetail;
80
80
  }
81
81
 
82
- /** Inline notice after a workflow run is destructively killed and removed. */
82
+ /** Inline notice after a workflow run is killed and retained for inspection. */
83
83
  export interface KilledPayload {
84
84
  kind: "killed";
85
85
  run: RunSnapshot;
86
86
  previousStatus: RunStatus;
87
- wasInFlight: boolean;
88
87
  }
89
88
 
90
89
  export type ChatSurfacePayload =
@@ -136,16 +135,8 @@ export function registerChatSurfaceRenderer(
136
135
  return makeComponent(payload, theme);
137
136
  };
138
137
 
139
- // The project's local `ExtensionAPI` types `registerMessageRenderer` as
140
- // returning a plain string. pi's runtime also accepts a Component (see
141
- // docs/extensions.md §Custom UI). Cast through `unknown` so the call
142
- // typechecks against both shapes. `.call(pi, …)` preserves `this` for
143
- // pi's class-backed ExtensionAPI.
144
- (register as unknown as (event: string, r: RawRenderer) => void).call(
145
- pi,
146
- CHAT_SURFACE_CUSTOM_TYPE,
147
- renderer,
148
- );
138
+ // `.call(pi, …)` preserves `this` for pi's class-backed ExtensionAPI.
139
+ register.call(pi, CHAT_SURFACE_CUSTOM_TYPE, renderer);
149
140
  rendererRegisteredHosts.add(pi);
150
141
  }
151
142
 
@@ -234,7 +225,6 @@ function renderPayload(
234
225
  theme,
235
226
  run: payload.run,
236
227
  previousStatus: payload.previousStatus,
237
- wasInFlight: payload.wasInFlight,
238
228
  }).join("\n");
239
229
  }
240
230
  }
@@ -124,16 +124,8 @@ export function registerInlineFormRenderer(pi: ExtensionAPI, theme: GraphTheme):
124
124
  };
125
125
  };
126
126
 
127
- // The project's local `ExtensionAPI` types `registerMessageRenderer` as
128
- // returning a plain string. The real pi runtime also accepts a Component
129
- // (see docs/extensions.md §Custom UI). Cast through `unknown` so the call
130
- // typechecks against both shapes. Call through `pi` so pi's
131
- // class-backed ExtensionAPI keeps its `this` binding.
132
- (register as unknown as (event: string, r: RawRenderer) => void).call(
133
- pi,
134
- CUSTOM_TYPE,
135
- renderer,
136
- );
127
+ // Call through `pi` so pi's class-backed ExtensionAPI keeps its `this` binding.
128
+ register.call(pi, CUSTOM_TYPE, renderer);
137
129
  rendererRegisteredHosts.add(pi);
138
130
  }
139
131
 
@@ -19,7 +19,7 @@ import type { Store } from "../shared/store.js";
19
19
  import type { ChatMessageRenderOptions, ReadonlyFooterDataProvider } from "@bastani/atomic";
20
20
  import { WorkflowAttachPane } from "./workflow-attach-pane.js";
21
21
  import { deriveGraphThemeFromPiTheme } from "./graph-theme.js";
22
- import { destroyRun } from "../runs/background/status.js";
22
+ import { killRun as defaultKillRun } from "../runs/background/status.js";
23
23
  import { cancellationRegistry } from "../runs/background/cancellation-registry.js";
24
24
  import { stageControlRegistry as defaultStageControlRegistry } from "../runs/foreground/stage-control-registry.js";
25
25
  import type { StageControlRegistry } from "../runs/foreground/stage-control-registry.js";
@@ -109,9 +109,9 @@ export interface BuildGraphOverlayAdapterOpts {
109
109
  /** Broker used to route stage-local custom UI into attached stage chats. */
110
110
  stageUiBroker?: StageUiBroker;
111
111
  /**
112
- * Destructive kill hook used by graph-mode `q`. The extension factory
113
- * supplies this so persistence can record a terminal event before the run is
114
- * removed from live history/status.
112
+ * Kill hook used by graph-mode `q`. The extension factory supplies this so
113
+ * persistence can record a terminal event while retaining the run for
114
+ * inspection.
115
115
  */
116
116
  onKillRun?: (runId: string) => void;
117
117
  }
@@ -124,7 +124,7 @@ export function buildGraphOverlayAdapter(
124
124
  const registry = buildOpts.stageControlRegistry ?? defaultStageControlRegistry;
125
125
  const stageUiBroker = buildOpts.stageUiBroker;
126
126
  const killRun = buildOpts.onKillRun ?? ((id: string): void => {
127
- destroyRun(id, { store, cancellation: cancellationRegistry });
127
+ defaultKillRun(id, { store, cancellation: cancellationRegistry });
128
128
  });
129
129
  let currentView: WorkflowAttachPane | null = null;
130
130
  // pi-tui returns an OverlayHandle via `options.onHandle`. We hold onto