@bastani/atomic 0.8.19-0 → 0.8.20-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 (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 +5 -0
  4. package/dist/builtin/mcp/package.json +2 -2
  5. package/dist/builtin/subagents/CHANGELOG.md +12 -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 +5 -0
  29. package/dist/builtin/web-access/package.json +2 -2
  30. package/dist/builtin/workflows/CHANGELOG.md +19 -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
@@ -15,9 +15,8 @@ import { restoreOnSessionStart } from "../shared/persistence-restore.js";
15
15
  import type { SessionManager } from "../shared/persistence-restore.js";
16
16
  import { installCompactionHook } from "../shared/persistence-compaction-policy.js";
17
17
  import {
18
+ killRun,
18
19
  killAllRuns,
19
- destroyRun,
20
- destroyAllRuns,
21
20
  resumeRun,
22
21
  pauseRun,
23
22
  pauseAllRuns,
@@ -65,6 +64,15 @@ import {
65
64
  WORKFLOW_CONFIG_DEFAULTS,
66
65
  withWorkflowDefaults,
67
66
  } from "./config-loader.js";
67
+ import {
68
+ createWorkflowLifecycleNotificationState,
69
+ installWorkflowLifecycleNotifications,
70
+ registerLifecycleNoticeRenderer,
71
+ resetWorkflowLifecycleNotificationState,
72
+ seedWorkflowLifecycleNotificationState,
73
+ withWorkflowLifecycleNotificationsSuppressed,
74
+ } from "./lifecycle-notifications.js";
75
+ import type { WorkflowLifecycleNotificationConfig } from "./lifecycle-notifications.js";
68
76
  import type { ConfigLoadResult } from "./config-loader.js";
69
77
  import type {
70
78
  WorkflowPersistencePort,
@@ -85,7 +93,7 @@ import type { StatusWriter } from "./status-writer.js";
85
93
  import { setMcpScope, clearMcpScope } from "./mcp.js";
86
94
  import type { PiMcpExtensionAPI, PiEventBus } from "./mcp.js";
87
95
  import type { StageSessionRuntime } from "../runs/foreground/stage-runner.js";
88
- import type { CreateAgentSessionOptions } from "@bastani/atomic";
96
+ import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, getEnvValue, type CreateAgentSessionOptions } from "@bastani/atomic";
89
97
 
90
98
  // ---------------------------------------------------------------------------
91
99
  // Minimal ExtensionAPI structural types
@@ -114,6 +122,13 @@ export interface PiRenderComponent {
114
122
  includes(searchString: string): boolean;
115
123
  }
116
124
 
125
+ export interface PiMessageRenderComponent {
126
+ render(width: number): string[];
127
+ invalidate?: () => void;
128
+ }
129
+
130
+ export type PiMessageRendererResult = string | PiMessageRenderComponent | undefined;
131
+
117
132
  function textRenderComponent(text: string): PiRenderComponent {
118
133
  return dynamicTextRenderComponent(() => text);
119
134
  }
@@ -180,6 +195,11 @@ export interface PiCommandContext extends PiModelContext {
180
195
  ui: {
181
196
  notify: (message: string, type?: "info" | "warning" | "error") => void;
182
197
  } & PiUISurface;
198
+ /**
199
+ * False when the host bound a no-op UI surface (print/JSON `-p` modes).
200
+ * Absent on older hosts and unit-test stubs; treat absence as interactive.
201
+ */
202
+ hasUI?: boolean;
183
203
  }
184
204
 
185
205
  /** CLI flag registration options. Mirrors the inline options on `ExtensionAPI.registerFlag`. */
@@ -240,6 +260,7 @@ export interface PiExecuteContext extends PiModelContext {
240
260
  sessionId?: string;
241
261
  ui?: PiUISurface;
242
262
  hasUI?: boolean;
263
+ orchestrationContext?: CreateAgentSessionOptions["orchestrationContext"];
243
264
  sessionManager?: SessionManager & {
244
265
  getSessionFile?: () => string | undefined;
245
266
  };
@@ -273,7 +294,7 @@ export interface ExtensionAPI {
273
294
  registerCommand?: (name: string, options: PiCommandOptions) => void;
274
295
  registerMessageRenderer?: (
275
296
  event: string,
276
- renderer: (payload: unknown) => string,
297
+ renderer: (payload: unknown) => PiMessageRendererResult,
277
298
  ) => void;
278
299
  /**
279
300
  * Inject a custom message into chat history. Used by inline workflow surfaces
@@ -306,6 +327,17 @@ export interface ExtensionAPI {
306
327
  handler: (ctx?: PiCommandContext) => void | Promise<void>;
307
328
  },
308
329
  ) => void;
330
+ /**
331
+ * Read the model's currently-active tool names. Present on pi's ExtensionAPI;
332
+ * absent on older runtimes.
333
+ */
334
+ getActiveTools?: () => string[];
335
+ /**
336
+ * Replace the model's active tool set by name. Used to drop the `workflow`
337
+ * tool in non-interactive sessions. Present on pi's ExtensionAPI; absent on
338
+ * older runtimes.
339
+ */
340
+ setActiveTools?: (toolNames: string[]) => void;
309
341
  /**
310
342
  * Sets the current session name. Present on pi's ExtensionAPI.
311
343
  */
@@ -896,6 +928,10 @@ function snapshotTranscriptEntries(
896
928
  return sortTranscriptEntriesChronologically(entries);
897
929
  }
898
930
 
931
+ function formatAlreadyEndedRetainedMessage(runId: string): string {
932
+ return `Run ${runId.slice(0, 8)} already ended; retained for inspection.`;
933
+ }
934
+
899
935
  function stageFailureMessage(
900
936
  runId: string,
901
937
  resultReason: string,
@@ -937,6 +973,49 @@ function reloadFailureMessage(error: unknown): string {
937
973
  return `Reload failed: ${error instanceof Error ? error.message : String(error)}`;
938
974
  }
939
975
 
976
+ function hasWorkflowStageSubagentGuardEnv(): boolean {
977
+ return getEnvValue(WORKFLOW_STAGE_SUBAGENT_GUARD_ENV) === "1";
978
+ }
979
+
980
+ function isWorkflowStageToolContext(ctx: PiExecuteContext): boolean {
981
+ return hasWorkflowStageSubagentGuardEnv() || ctx.orchestrationContext?.kind === "workflow-stage";
982
+ }
983
+
984
+ /** Tool name registered for workflow execution; shared by the de-advertise guard. */
985
+ const WORKFLOW_TOOL_NAME = "workflow";
986
+
987
+ /**
988
+ * User-facing message shown when the `/workflow` command is invoked in a
989
+ * non-interactive (`-p` / `--print` / `--mode json`) session. Such sessions
990
+ * bind a no-op UI surface (`ctx.hasUI === false`) and cannot drive workflow
991
+ * pickers, the graph overlay, or human-in-the-loop prompts.
992
+ *
993
+ * The `workflow` tool is removed from the model's tool set in these sessions
994
+ * (see `deAdvertiseWorkflowToolWhenHeadless`). The slash command has no
995
+ * advertise layer and is still reachable via `atomic -p "/workflow …"` (print
996
+ * mode dispatches leading-slash commands through `session.prompt`), so it is
997
+ * refused here instead.
998
+ */
999
+ export const WORKFLOW_NON_INTERACTIVE_MESSAGE =
1000
+ "Workflows are disabled in non-interactive (-p) mode; run Atomic interactively to use workflows.";
1001
+
1002
+ /**
1003
+ * Remove the `workflow` tool from the model's active tool set in non-interactive
1004
+ * (`-p` / `--mode json`) sessions, which bind a no-op UI (`ctx.hasUI === false`)
1005
+ * and cannot drive workflow prompts or the graph overlay. Invoked from
1006
+ * `session_start`, which the host awaits before the first prompt, so the tool is
1007
+ * gone before the model's first turn. Interactive and RPC modes bind a real UI
1008
+ * context (`hasUI: true`) and keep the tool. No-ops on hosts that predate the
1009
+ * `getActiveTools`/`setActiveTools` extension API.
1010
+ */
1011
+ function deAdvertiseWorkflowToolWhenHeadless(pi: ExtensionAPI, hasUI: boolean | undefined): void {
1012
+ if (hasUI !== false) return;
1013
+ if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
1014
+ const active = pi.getActiveTools();
1015
+ if (!active.includes(WORKFLOW_TOOL_NAME)) return;
1016
+ pi.setActiveTools(active.filter((name) => name !== WORKFLOW_TOOL_NAME));
1017
+ }
1018
+
940
1019
  // ---------------------------------------------------------------------------
941
1020
  // Tool execute — dispatch with real registry for list/inputs/run (Phase E)
942
1021
  // + real status/interrupt/resume (Phase D)
@@ -953,6 +1032,19 @@ export function makeExecuteWorkflowTool(
953
1032
  ): Promise<WorkflowToolResult> {
954
1033
  const action = args.action ?? "run";
955
1034
  const runId = args.runId ?? "";
1035
+ if (isWorkflowStageToolContext(ctx)) {
1036
+ // Workflow stages must not invoke the workflow tool at all, including
1037
+ // read-only inspection actions. The tool is normally excluded from stage
1038
+ // sessions; this guard is a defense-in-depth fallback for stale or
1039
+ // hand-crafted registrations.
1040
+ return {
1041
+ action: "run",
1042
+ runId,
1043
+ status: "failed",
1044
+ error: "workflows cannot invoke workflows from workflow stages",
1045
+ stages: [],
1046
+ };
1047
+ }
956
1048
  const activeRuntime =
957
1049
  typeof runtime === "function" ? runtime(ctx) : runtime;
958
1050
 
@@ -998,9 +1090,9 @@ export function makeExecuteWorkflowTool(
998
1090
  error: `run not found: ${target}`,
999
1091
  };
1000
1092
  }
1001
- // List mode — emit live snapshots; the renderer produces the
1093
+ // List mode — emit all retained snapshots; the renderer produces the
1002
1094
  // canonical band + card surface.
1003
- const snapshots = store.runs().filter((r) => r.endedAt === undefined);
1095
+ const snapshots = store.runs();
1004
1096
  return {
1005
1097
  action: "status",
1006
1098
  snapshots: snapshots.map((snapshot) => structuredClone(snapshot)),
@@ -1319,7 +1411,7 @@ export function makeExecuteWorkflowTool(
1319
1411
  message: allStageConflictMessage("kill"),
1320
1412
  };
1321
1413
  }
1322
- const results = destroyAllRuns({
1414
+ const results = killAllRuns({
1323
1415
  cancellation: cancellationRegistry,
1324
1416
  persistence: getPersistence(),
1325
1417
  });
@@ -1330,7 +1422,7 @@ export function makeExecuteWorkflowTool(
1330
1422
  status: killed > 0 ? "killed" : "noop",
1331
1423
  message:
1332
1424
  killed > 0
1333
- ? `Killed and removed ${killed} run(s).`
1425
+ ? `Killed and retained ${killed} run(s) for inspection.`
1334
1426
  : "No in-flight runs to kill.",
1335
1427
  };
1336
1428
  }
@@ -1340,7 +1432,7 @@ export function makeExecuteWorkflowTool(
1340
1432
  if (target.kind === "not_found") {
1341
1433
  return { action, runId: target.target, status: "noop", message: target.message };
1342
1434
  }
1343
- const result = destroyRun(target.runId, {
1435
+ const result = killRun(target.runId, {
1344
1436
  cancellation: cancellationRegistry,
1345
1437
  persistence: getPersistence(),
1346
1438
  });
@@ -1349,14 +1441,17 @@ export function makeExecuteWorkflowTool(
1349
1441
  action,
1350
1442
  runId: result.runId,
1351
1443
  status: "killed",
1352
- message: `Run ${result.runId} killed and removed (was ${result.previousStatus}).`,
1444
+ message: `Run ${result.runId} killed and retained for inspection (was ${result.previousStatus}).`,
1353
1445
  };
1354
1446
  }
1355
1447
  return {
1356
1448
  action,
1357
1449
  runId: target.runId,
1358
1450
  status: "noop",
1359
- message: `Run not found: ${target.runId}`,
1451
+ message: result.reason === "already_ended"
1452
+ ? formatAlreadyEndedRetainedMessage(target.runId)
1453
+ // Defensive fallback: resolveRunTarget already found this run, and killRun no longer removes runs.
1454
+ : `Run not found: ${target.runId}`,
1360
1455
  };
1361
1456
  }
1362
1457
 
@@ -1908,7 +2003,7 @@ function factory(pi: ExtensionAPI): void {
1908
2003
  const overlay: GraphOverlayPort = buildGraphOverlayAdapter(pi, store, {
1909
2004
  onKillRun: (runId) => {
1910
2005
  const run = store.runs().find((r) => r.id === runId);
1911
- const result = destroyRun(runId, {
2006
+ const result = killRun(runId, {
1912
2007
  cancellation: cancellationRegistry,
1913
2008
  persistence: persistenceRef.current,
1914
2009
  });
@@ -1917,7 +2012,6 @@ function factory(pi: ExtensionAPI): void {
1917
2012
  kind: "killed",
1918
2013
  run,
1919
2014
  previousStatus: result.previousStatus,
1920
- wasInFlight: result.wasInFlight,
1921
2015
  });
1922
2016
  }
1923
2017
  },
@@ -1950,6 +2044,32 @@ function factory(pi: ExtensionAPI): void {
1950
2044
  store,
1951
2045
  runtimeConfigRef.current,
1952
2046
  );
2047
+ let lifecycleNotificationsUnsubscribe: (() => void) | null = null;
2048
+ let lifecycleNotificationsActive = false;
2049
+ const lifecycleNotificationState = createWorkflowLifecycleNotificationState();
2050
+ const lifecycleNotificationConfigRef: { current: WorkflowLifecycleNotificationConfig } = {
2051
+ current: WORKFLOW_CONFIG_DEFAULTS.workflowNotifications,
2052
+ };
2053
+ registerLifecycleNoticeRenderer({
2054
+ rendererHost: pi,
2055
+ registerMessageRenderer: pi.registerMessageRenderer
2056
+ ? (event, renderer) => pi.registerMessageRenderer?.(event, renderer)
2057
+ : undefined,
2058
+ });
2059
+ const reinstallLifecycleNotifications = (): void => {
2060
+ lifecycleNotificationsUnsubscribe?.();
2061
+ lifecycleNotificationsUnsubscribe = null;
2062
+ if (!lifecycleNotificationsActive) return;
2063
+ lifecycleNotificationsUnsubscribe = installWorkflowLifecycleNotifications({
2064
+ store,
2065
+ config: lifecycleNotificationConfigRef.current,
2066
+ state: lifecycleNotificationState,
2067
+ seedExisting: true,
2068
+ sendMessage: pi.sendMessage
2069
+ ? (message, options) => pi.sendMessage?.(message, options)
2070
+ : undefined,
2071
+ });
2072
+ };
1953
2073
  let intercomParentSession: string | null = null;
1954
2074
  const intercomPort = {
1955
2075
  emit:
@@ -2100,6 +2220,8 @@ function factory(pi: ExtensionAPI): void {
2100
2220
  statusFile: effectiveConfig.statusFile,
2101
2221
  resumeInFlight: effectiveConfig.resumeInFlight,
2102
2222
  };
2223
+ lifecycleNotificationConfigRef.current = effectiveConfig.workflowNotifications;
2224
+ reinstallLifecycleNotifications();
2103
2225
 
2104
2226
  // Replace status writer with one that reflects the resolved config.
2105
2227
  // Unsubscribe the prior (no-op) writer before creating the new one.
@@ -2182,7 +2304,7 @@ function factory(pi: ExtensionAPI): void {
2182
2304
  * connect [runId|prefix] no arg → picker overlay; arg → attach to graph
2183
2305
  * attach [runId|prefix [stageId]] open the in-place attach pane on a stage
2184
2306
  * interrupt [runId|prefix|--all] [-y] confirmation overlay unless -y
2185
- * kill [runId|prefix|--all] [-y] kill and remove from history/status
2307
+ * kill [runId|prefix|--all] [-y] kill and retain for inspection
2186
2308
  * pause [runId|prefix [stageId]] pause a run or specific stage
2187
2309
  * resume [runId|prefix [stageId] …] resume paused work or reopen snapshot
2188
2310
  */
@@ -2201,7 +2323,7 @@ function factory(pi: ExtensionAPI): void {
2201
2323
  const ui = ctx.ui;
2202
2324
  if (!ui || typeof ui.custom !== "function") {
2203
2325
  print(
2204
- `${renderSessionList(store.runs(), { theme, includeAll: false })}\n\nPicker requires a UI surface. Pass a runId: /workflow connect <id>`,
2326
+ `${renderSessionList(store.runs(), { theme, includeAll: true })}\n\nPicker requires a UI surface. Pass a runId: /workflow connect <id>`,
2205
2327
  );
2206
2328
  return true;
2207
2329
  }
@@ -2217,6 +2339,10 @@ function factory(pi: ExtensionAPI): void {
2217
2339
  print(`Run not found: ${result.runId}`);
2218
2340
  return true;
2219
2341
  }
2342
+ if (run.endedAt !== undefined) {
2343
+ print(formatAlreadyEndedRetainedMessage(result.runId));
2344
+ return true;
2345
+ }
2220
2346
  const confirmed = await openKillConfirm(ui, run, theme);
2221
2347
  if (!confirmed) {
2222
2348
  print(
@@ -2224,7 +2350,7 @@ function factory(pi: ExtensionAPI): void {
2224
2350
  );
2225
2351
  return true;
2226
2352
  }
2227
- const killed = destroyRun(result.runId, {
2353
+ const killed = killRun(result.runId, {
2228
2354
  cancellation: cancellationRegistry,
2229
2355
  persistence: persistenceRef.current,
2230
2356
  });
@@ -2233,13 +2359,14 @@ function factory(pi: ExtensionAPI): void {
2233
2359
  kind: "killed",
2234
2360
  run,
2235
2361
  previousStatus: killed.previousStatus,
2236
- wasInFlight: killed.wasInFlight,
2237
2362
  });
2238
2363
  }
2239
2364
  print(
2240
2365
  killed.ok
2241
- ? `Run ${killed.runId.slice(0, 8)} killed and removed.`
2242
- : `Run not found: ${result.runId.slice(0, 8)}.`,
2366
+ ? `Run ${killed.runId.slice(0, 8)} killed and retained for inspection.`
2367
+ : killed.reason === "already_ended"
2368
+ ? formatAlreadyEndedRetainedMessage(killed.runId)
2369
+ : `Run not found: ${result.runId.slice(0, 8)}.`,
2243
2370
  );
2244
2371
  return true;
2245
2372
  }
@@ -2367,7 +2494,7 @@ function factory(pi: ExtensionAPI): void {
2367
2494
  }
2368
2495
  if (!yes && ctx.ui && typeof ctx.ui.confirm === "function") {
2369
2496
  const ok = await ctx.ui.confirm(
2370
- `Kill and remove all ${inFlight.length} in-flight workflow runs?`,
2497
+ `Kill ${inFlight.length} in-flight workflow runs? Killed runs are retained for inspection.`,
2371
2498
  `Aborts: ${inFlight.map((r) => `${r.name} (${r.id.slice(0, 8)})`).join(", ")}`,
2372
2499
  );
2373
2500
  if (!ok) {
@@ -2375,14 +2502,14 @@ function factory(pi: ExtensionAPI): void {
2375
2502
  return true;
2376
2503
  }
2377
2504
  }
2378
- const results = destroyAllRuns({
2505
+ const results = killAllRuns({
2379
2506
  cancellation: cancellationRegistry,
2380
2507
  persistence: persistenceRef.current,
2381
2508
  });
2382
2509
  const killed = results.filter((r) => r.ok).length;
2383
2510
  print(
2384
2511
  killed > 0
2385
- ? `Killed and removed ${killed} run(s).`
2512
+ ? `Killed and retained ${killed} run(s) for inspection.`
2386
2513
  : "No in-flight runs to kill.",
2387
2514
  );
2388
2515
  return true;
@@ -2401,6 +2528,10 @@ function factory(pi: ExtensionAPI): void {
2401
2528
  return true;
2402
2529
  }
2403
2530
  const run = store.runs().find((r) => r.id === resolved.runId);
2531
+ if (run?.endedAt !== undefined) {
2532
+ print(formatAlreadyEndedRetainedMessage(resolved.runId));
2533
+ return true;
2534
+ }
2404
2535
  if (!yes && run && ctx.ui) {
2405
2536
  const confirmed = await openKillConfirm(ctx.ui, run, theme);
2406
2537
  if (!confirmed) {
@@ -2410,7 +2541,7 @@ function factory(pi: ExtensionAPI): void {
2410
2541
  return true;
2411
2542
  }
2412
2543
  }
2413
- const result = destroyRun(resolved.runId, {
2544
+ const result = killRun(resolved.runId, {
2414
2545
  cancellation: cancellationRegistry,
2415
2546
  persistence: persistenceRef.current,
2416
2547
  });
@@ -2420,14 +2551,17 @@ function factory(pi: ExtensionAPI): void {
2420
2551
  kind: "killed",
2421
2552
  run,
2422
2553
  previousStatus: result.previousStatus,
2423
- wasInFlight: result.wasInFlight,
2424
2554
  });
2425
2555
  }
2426
2556
  print(
2427
- `Run ${result.runId.slice(0, 8)} killed and removed (was ${result.previousStatus}).`,
2557
+ `Run ${result.runId.slice(0, 8)} killed and retained for inspection (was ${result.previousStatus}).`,
2428
2558
  );
2429
2559
  } else {
2430
- print(`Run not found: ${target}`);
2560
+ print(
2561
+ result.reason === "already_ended"
2562
+ ? formatAlreadyEndedRetainedMessage(result.runId)
2563
+ : `Run not found: ${target}`,
2564
+ );
2431
2565
  }
2432
2566
  return true;
2433
2567
  }
@@ -2440,7 +2574,7 @@ function factory(pi: ExtensionAPI): void {
2440
2574
  const ui = ctx.ui;
2441
2575
  if (!ui || typeof ui.custom !== "function") {
2442
2576
  print(
2443
- `${renderSessionList(store.runs(), { theme, includeAll: false })}\n\nPicker requires a UI surface. Pass a runId: /workflow attach <id> [stageId]`,
2577
+ `${renderSessionList(store.runs(), { theme, includeAll: true })}\n\nPicker requires a UI surface. Pass a runId: /workflow attach <id> [stageId]`,
2444
2578
  );
2445
2579
  return true;
2446
2580
  }
@@ -2657,6 +2791,13 @@ function factory(pi: ExtensionAPI): void {
2657
2791
  description:
2658
2792
  "Run or inspect pi workflows. Usage: /workflow <name> [key=value…] | /workflow [list|status|connect|attach|interrupt|kill|pause|resume|inputs|reload] [args]",
2659
2793
  handler: async (args: string, ctx: PiCommandContext) => {
2794
+ // Print/JSON (`-p`) sessions cannot drive workflow pickers, the graph
2795
+ // overlay, or human-in-the-loop prompts. Refuse before parsing so the
2796
+ // command surface matches the disabled `workflow` tool.
2797
+ if (ctx.hasUI === false) {
2798
+ ctx.ui.notify(WORKFLOW_NON_INTERACTIVE_MESSAGE, "warning");
2799
+ return;
2800
+ }
2660
2801
  const print = (msg: string): void => ctx.ui.notify(msg, "info");
2661
2802
  // Quote-aware split so `prompt="map the codebase"` stays a single
2662
2803
  // token. Plain `.split(/\s+/)` would mangle quoted multi-word values
@@ -2702,9 +2843,8 @@ function factory(pi: ExtensionAPI): void {
2702
2843
 
2703
2844
  // -----------------------------------------------------------------------
2704
2845
  // status — band-header rich list, or per-run detail when an id is
2705
- // supplied. `/workflow status` lists everything in-flight (`--all`
2706
- // includes ended runs older than an hour); `/workflow status <id>`
2707
- // drills into a single run via the inspectRun detail block.
2846
+ // supplied. `/workflow status` lists all retained snapshots; `/workflow
2847
+ // status <id>` drills into a single run via the inspectRun detail block.
2708
2848
  // -----------------------------------------------------------------------
2709
2849
  if (subcommand === "status") {
2710
2850
  const target = parts[1];
@@ -2730,13 +2870,12 @@ function factory(pi: ExtensionAPI): void {
2730
2870
  emitChatSurface(pi, { kind: "detail", detail: inspected.detail });
2731
2871
  return;
2732
2872
  }
2733
- // Mirror renderSessionList's filter: keep `--all` semantics, then
2734
- // hand the already-filtered snapshot to the chat-surface renderer.
2735
- const includeAll = parts.includes("--all");
2873
+ // Status lists all retained snapshots by default; --all remains
2874
+ // accepted as a compatibility no-op.
2736
2875
  const rows = selectRunsForPicker(
2737
2876
  store.runs(),
2738
2877
  "",
2739
- includeAll,
2878
+ true,
2740
2879
  Date.now(),
2741
2880
  );
2742
2881
  emitChatSurface(pi, { kind: "status", runs: rows.map((r) => r.run) });
@@ -2782,7 +2921,7 @@ function factory(pi: ExtensionAPI): void {
2782
2921
  }
2783
2922
 
2784
2923
  // -----------------------------------------------------------------------
2785
- // kill — destructive fast path: abort and remove from history/status.
2924
+ // kill — abort in-flight work, mark killed, and retain for inspection.
2786
2925
  // -----------------------------------------------------------------------
2787
2926
  if (subcommand === "kill") {
2788
2927
  const killArgs = parts.slice(1);
@@ -3061,7 +3200,7 @@ function factory(pi: ExtensionAPI): void {
3061
3200
  {
3062
3201
  value: "status ",
3063
3202
  label: "status",
3064
- description: "List in-flight runs",
3203
+ description: "List current-session active and retained terminal runs",
3065
3204
  },
3066
3205
  {
3067
3206
  value: "interrupt ",
@@ -3071,7 +3210,7 @@ function factory(pi: ExtensionAPI): void {
3071
3210
  {
3072
3211
  value: "kill ",
3073
3212
  label: "kill",
3074
- description: "Kill and remove a run",
3213
+ description: "Kill and retain a run for inspection",
3075
3214
  },
3076
3215
  {
3077
3216
  value: "pause ",
@@ -3109,14 +3248,7 @@ function factory(pi: ExtensionAPI): void {
3109
3248
  }
3110
3249
 
3111
3250
  if (subcommand === "status") {
3112
- return completeToken(partial, [
3113
- {
3114
- value: "--all ",
3115
- label: "--all",
3116
- description: "Include recently ended runs",
3117
- },
3118
- ...runIdItems(),
3119
- ]);
3251
+ return completeToken(partial, runIdItems());
3120
3252
  }
3121
3253
 
3122
3254
  if (subcommand === "connect") {
@@ -3132,7 +3264,7 @@ function factory(pi: ExtensionAPI): void {
3132
3264
  }
3133
3265
 
3134
3266
  if (subcommand === "interrupt" || subcommand === "kill") {
3135
- const verb = subcommand === "kill" ? "Kill and remove" : "Interrupt";
3267
+ const verb = subcommand === "kill" ? "Kill and retain" : "Interrupt";
3136
3268
  return completeToken(partial, [
3137
3269
  {
3138
3270
  value: "--all ",
@@ -3256,18 +3388,60 @@ function factory(pi: ExtensionAPI): void {
3256
3388
  // 4. Persistence: session_start restore + session_before_compact hook (§5.6, Phase D)
3257
3389
  // -------------------------------------------------------------------------
3258
3390
  if (typeof pi.on === "function") {
3391
+ pi.on("session_before_switch", async (event, ctx) => {
3392
+ const reason = typeof event === "object" && event !== null && "reason" in event
3393
+ ? (event as { readonly reason?: string }).reason
3394
+ : undefined;
3395
+ if (reason !== "new" && reason !== "resume") return undefined;
3396
+
3397
+ // "In-flight" intentionally includes paused runs: session_start kills any run
3398
+ // without endedAt, so warn for the same set that would be stopped by the switch.
3399
+ const inFlightWorkflowCount = inFlightRunCount();
3400
+ if (inFlightWorkflowCount === 0) return undefined;
3401
+
3402
+ const confirmSessionSwitch = ctx?.ui?.confirm;
3403
+ // Headless/non-interactive callers intentionally fail open so automation cannot wedge.
3404
+ if (typeof confirmSessionSwitch !== "function") return undefined;
3405
+
3406
+ const workflowNoun = inFlightWorkflowCount === 1 ? "workflow" : "workflows";
3407
+ const actionLabel = reason === "new" ? "Start a new session" : "Resume another session";
3408
+ const messageLabel = reason === "new" ? "Starting a new session" : "Resuming another session";
3409
+ const promptTitle = `${actionLabel} and stop ${inFlightWorkflowCount} in-flight ${workflowNoun}?`;
3410
+ const promptMessage =
3411
+ `${messageLabel} will stop/kill ${inFlightWorkflowCount} in-flight ${workflowNoun} and clear workflow history tied to the current session.`;
3412
+ let shouldSwitchSession: boolean | undefined;
3413
+ try {
3414
+ shouldSwitchSession = await confirmSessionSwitch(promptTitle, promptMessage);
3415
+ } catch {
3416
+ // Keep headless/failed UI behavior fail-open so session automation cannot wedge.
3417
+ return undefined;
3418
+ }
3419
+ if (shouldSwitchSession) return undefined;
3420
+
3421
+ const cancelledLabel = reason === "new" ? "New session" : "Resume";
3422
+ ctx?.ui?.notify?.(`${cancelledLabel} cancelled; in-flight workflows were left unchanged.`, "info");
3423
+ return { cancel: true };
3424
+ });
3425
+
3259
3426
  pi.on("session_start", async (_event, ctx) => {
3427
+ // Non-interactive (`-p` / `--mode json`) sessions cannot drive workflow
3428
+ // prompts or the graph overlay; drop the `workflow` tool from the model's
3429
+ // tool set before the first turn. The host awaits this handler during
3430
+ // bindExtensions, ahead of the initial prompt.
3431
+ deAdvertiseWorkflowToolWhenHeadless(pi, ctx?.hasUI);
3432
+
3260
3433
  // Workflow lifecycle is scoped to the originating chat session.
3261
- // A new session inherits a clean store; any leftover runs from a
3434
+ // A new session inherits a clean store; any leftover live runs from a
3262
3435
  // previous session in the same pi process are killed (subprocess
3263
- // aborted) and dropped. `restoreOnSessionStart` below then loads
3264
- // *this* session's persisted runs from disk.
3436
+ // aborted), then all stale snapshots are cleared. `restoreOnSessionStart`
3437
+ // below loads *this* session's persisted runs from disk.
3265
3438
  killAllRuns({
3266
3439
  store,
3267
3440
  cancellation: cancellationRegistry,
3268
3441
  persistence: persistenceRef.current,
3269
3442
  });
3270
3443
  store.clear();
3444
+ resetWorkflowLifecycleNotificationState(lifecycleNotificationState);
3271
3445
  stageControlRegistry.clear();
3272
3446
 
3273
3447
  // pi-intercom session naming lives here so we don't trip the
@@ -3278,6 +3452,8 @@ function factory(pi: ExtensionAPI): void {
3278
3452
  // Ensure config+discovery are ready before restoring in-flight runs —
3279
3453
  // tunables must be resolved first.
3280
3454
  await discoveryPromise;
3455
+ lifecycleNotificationsActive = true;
3456
+ reinstallLifecycleNotifications();
3281
3457
  if (ctx?.ui) {
3282
3458
  const diagnostics = formatStartupDiagnostics(configLoadRef.current, discoveryRef.current);
3283
3459
  if (diagnostics !== null) {
@@ -3290,13 +3466,25 @@ function factory(pi: ExtensionAPI): void {
3290
3466
  const sessionManager = ctx?.sessionManager ?? pi.sessionManager;
3291
3467
  if (sessionManager) {
3292
3468
  const cfg = configLoadRef.current?.config;
3293
- restoreOnSessionStart(
3294
- sessionManager,
3295
- {
3296
- resumeInFlight: cfg?.resumeInFlight ?? "ask",
3297
- persistRuns: cfg?.persistRuns ?? true,
3469
+ withWorkflowLifecycleNotificationsSuppressed(
3470
+ lifecycleNotificationState,
3471
+ () => {
3472
+ restoreOnSessionStart(
3473
+ sessionManager,
3474
+ {
3475
+ resumeInFlight: cfg?.resumeInFlight ?? "ask",
3476
+ persistRuns: cfg?.persistRuns ?? true,
3477
+ },
3478
+ store,
3479
+ );
3480
+ // The suppressed subscriber observes restore replay and marks matching
3481
+ // notices delivered. Seed explicitly as a defensive backstop for
3482
+ // runtimes without a lifecycle-notification subscriber installed.
3483
+ seedWorkflowLifecycleNotificationState(
3484
+ lifecycleNotificationState,
3485
+ store.snapshot(),
3486
+ );
3298
3487
  },
3299
- store,
3300
3488
  );
3301
3489
  }
3302
3490
  });
@@ -3320,6 +3508,9 @@ function factory(pi: ExtensionAPI): void {
3320
3508
  }
3321
3509
  storeWidgetUnsubscribe?.();
3322
3510
  storeWidgetUnsubscribe = null;
3511
+ lifecycleNotificationsActive = false;
3512
+ lifecycleNotificationsUnsubscribe?.();
3513
+ lifecycleNotificationsUnsubscribe = null;
3323
3514
  });
3324
3515
  }
3325
3516