@osolmaz/pi-workflows 0.4.0 → 0.5.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 (57) hide show
  1. package/README.md +8 -5
  2. package/dist/builtins/catalog.js +12 -2
  3. package/dist/builtins/catalog.js.map +1 -1
  4. package/dist/builtins/monitor.workflow.d.ts +2 -2
  5. package/dist/builtins/monitor.workflow.js +11 -27
  6. package/dist/builtins/monitor.workflow.js.map +1 -1
  7. package/dist/controllers/index.d.ts +1 -1
  8. package/dist/controllers/index.js.map +1 -1
  9. package/dist/controllers/sqlite.d.ts +43 -5
  10. package/dist/controllers/sqlite.js +128 -32
  11. package/dist/controllers/sqlite.js.map +1 -1
  12. package/dist/extension/index.js +109 -94
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/widget.d.ts +5 -2
  15. package/dist/extension/widget.js +52 -17
  16. package/dist/extension/widget.js.map +1 -1
  17. package/dist/host/runner.js +20 -1
  18. package/dist/host/runner.js.map +1 -1
  19. package/dist/render/graph-render.js +3 -0
  20. package/dist/render/graph-render.js.map +1 -1
  21. package/dist/workflows/definition.d.ts +2 -1
  22. package/dist/workflows/definition.js +9 -1
  23. package/dist/workflows/definition.js.map +1 -1
  24. package/dist/workflows/engine.d.ts +1 -0
  25. package/dist/workflows/engine.js +22 -0
  26. package/dist/workflows/engine.js.map +1 -1
  27. package/dist/workflows/index.d.ts +2 -2
  28. package/dist/workflows/index.js +1 -1
  29. package/dist/workflows/index.js.map +1 -1
  30. package/dist/workflows/migrate-sources.d.ts +1 -0
  31. package/dist/workflows/migrate-sources.js +4 -0
  32. package/dist/workflows/migrate-sources.js.map +1 -1
  33. package/dist/workflows/schema.d.ts +2 -1
  34. package/dist/workflows/schema.js +12 -0
  35. package/dist/workflows/schema.js.map +1 -1
  36. package/dist/workflows/store.js +3 -0
  37. package/dist/workflows/store.js.map +1 -1
  38. package/dist/workflows/types.d.ts +26 -1
  39. package/docs/plans/2026-08-13-responsive-workflow-widget-plan.md +64 -0
  40. package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
  41. package/docs/workflows.md +23 -4
  42. package/package.json +3 -1
  43. package/src/builtins/catalog.ts +12 -2
  44. package/src/builtins/monitor.workflow.ts +11 -28
  45. package/src/controllers/index.ts +1 -0
  46. package/src/controllers/sqlite.ts +225 -33
  47. package/src/extension/index.ts +123 -125
  48. package/src/extension/widget.ts +83 -20
  49. package/src/host/runner.ts +20 -1
  50. package/src/render/graph-render.ts +3 -0
  51. package/src/workflows/definition.ts +11 -0
  52. package/src/workflows/engine.ts +26 -0
  53. package/src/workflows/index.ts +5 -0
  54. package/src/workflows/migrate-sources.ts +7 -0
  55. package/src/workflows/schema.ts +14 -0
  56. package/src/workflows/store.ts +3 -0
  57. package/src/workflows/types.ts +30 -0
@@ -2,11 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
5
- import {
6
- projectControllerStorePath,
7
- type RunEventRecord,
8
- SqliteControllerStore,
9
- } from "../controllers/index.js";
5
+ import { projectControllerStorePath, SqliteControllerStore } from "../controllers/index.js";
10
6
  import type { JsonObject } from "../controllers/types.js";
11
7
  import type { WorkflowSchedulerResult } from "../controllers/workflows.js";
12
8
  import { WorkflowEngine } from "../workflows/engine.js";
@@ -40,12 +36,13 @@ import {
40
36
  } from "./controller-host.js";
41
37
  import { ConversationStepExecutor } from "./executor.js";
42
38
  import { SessionRecorder } from "./recorder.js";
43
- import { buildWidgetView } from "./widget.js";
39
+ import { buildWidgetView, type WidgetLayout, type WidgetScrollState } from "./widget.js";
44
40
  import { WorkflowToolParameters, type WorkflowToolInput } from "./workflow-tool.js";
45
41
 
46
42
  const RUN_CLAIM_LEASE_MS = 30_000;
47
43
  const RUN_CLAIM_RENEW_MS = 10_000;
48
44
  const RUN_SYNC_POLL_MS = 3_000;
45
+ const NOTIFICATION_DELIVERY_LEASE_MS = 30_000;
49
46
  const WIDGET_KEY = "pi-workflows";
50
47
  const PRESENTATION_MESSAGE_TYPE = "pi-workflows-presentation";
51
48
  const FINAL_WIDGET_TTL_MS = 60_000;
@@ -207,6 +204,14 @@ type WidgetSource = {
207
204
  snapshot: WorkflowDefinitionSnapshot;
208
205
  };
209
206
 
207
+ type WorkflowWidgetComponent = {
208
+ render: (width: number) => string[];
209
+ invalidate: () => void;
210
+ };
211
+
212
+ type WorkflowWidgetFactory = () => WorkflowWidgetComponent;
213
+ type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
214
+
210
215
  export default function piWorkflows(pi: ExtensionAPI) {
211
216
  // One runner identity per session; it names this session in run claims.
212
217
  const runnerId = randomUUID();
@@ -217,11 +222,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
217
222
  return runQueueStore;
218
223
  };
219
224
 
220
- // Session sync: a per-session watermark over the run event feed keeps this
221
- // session's context current with runs other runners drove.
222
- // The sync watermark is project-scoped: a reopened session catches up
223
- // where the last one stopped, and two open sessions share one pointer.
224
- const SYNC_WATERMARK_KEY = "project";
225
+ // Session-addressed delivery: each session polls only its durable outbox.
226
+ // Run events remain an audit feed and never enter a conversation.
225
227
  let syncArmed = false;
226
228
  let runSyncTimer: ReturnType<typeof setInterval> | null = null;
227
229
 
@@ -238,109 +240,54 @@ export default function piWorkflows(pi: ExtensionAPI) {
238
240
  }
239
241
  };
240
242
 
241
- const describeRunEvent = (event: RunEventRecord): string => {
242
- const label = `${event.workflowRef} run ${event.runId}`;
243
- switch (event.type) {
244
- case "waiting": {
245
- const waitingOn =
246
- typeof event.payload.waitingOn === "string" ? event.payload.waitingOn : "a checkpoint";
247
- return `${label} waits at checkpoint ${waitingOn} — answer with /workflow answer`;
243
+ const deliveredNotificationIds = (ctx: ExtensionContext): Set<string> => {
244
+ const ids = new Set<string>();
245
+ for (const entry of ctx.sessionManager.getBranch()) {
246
+ if (entry.type !== "custom_message" || entry.customType !== "pi-workflows-notification") {
247
+ continue;
248
248
  }
249
- case "parked":
250
- return `${label} was parked and will resume when a runner is available`;
251
- case "failed": {
252
- const detail = typeof event.payload.error === "string" ? `: ${event.payload.error}` : "";
253
- return `${label} failed${detail}`;
249
+ const details = entry.details;
250
+ if (details !== null && typeof details === "object" && !Array.isArray(details)) {
251
+ const notificationId = (details as { notificationId?: unknown }).notificationId;
252
+ if (typeof notificationId === "string") ids.add(notificationId);
254
253
  }
255
- default:
256
- return `${label} ${event.type}`;
257
254
  }
255
+ return ids;
258
256
  };
259
257
 
260
- const runSyncPass = async (ctx: ExtensionContext): Promise<void> => {
261
- if (runQueueStore === null || !syncArmed) {
262
- return;
263
- }
258
+ const runSyncPass = (ctx: ExtensionContext): void => {
259
+ if (runQueueStore === null || !syncArmed) return;
264
260
  try {
265
- const watermark = runQueueStore.getSessionWatermark(SYNC_WATERMARK_KEY);
266
- if (watermark === 0) {
267
- // First sync ever for this project: never replay the feed (stale
268
- // "waits at checkpoint" lines included). Fast-forward, then catch
269
- // up from current state instead — what is parked, resuming, or
270
- // waiting for an answer right now.
271
- const latest = runQueueStore.latestRunEventSeq();
272
- if (latest > 0) {
273
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, latest);
261
+ const sessionId = ctx.sessionManager.getSessionId();
262
+ const alreadyDelivered = deliveredNotificationIds(ctx);
263
+ const claimToken = randomUUID();
264
+ for (const notification of runQueueStore.claimPendingWorkflowNotifications({
265
+ targetSessionId: sessionId,
266
+ claimToken,
267
+ leaseMs: NOTIFICATION_DELIVERY_LEASE_MS,
268
+ })) {
269
+ if (!alreadyDelivered.has(notification.notificationId)) {
270
+ pi.sendMessage({
271
+ customType: "pi-workflows-notification",
272
+ content: notification.content,
273
+ display: true,
274
+ details: {
275
+ notificationId: notification.notificationId,
276
+ runId: notification.runId,
277
+ kind: notification.kind,
278
+ },
279
+ });
280
+ alreadyDelivered.add(notification.notificationId);
274
281
  }
275
- await sendStateSnapshot(ctx);
276
- return;
277
- }
278
- const events = runQueueStore.listRunEventsAfter(watermark, { limit: 20 });
279
- if (events.length === 0) {
280
- return;
281
- }
282
- // Persist the watermark first. A crash after this point skips the
283
- // message, but snapshots recompute from the store, so no information
284
- // stays lost; a duplicated state line is the worst outcome.
285
- runQueueStore.setSessionWatermark(SYNC_WATERMARK_KEY, events[events.length - 1]?.seq ?? 0);
286
- const noteworthy = events.filter(
287
- (event) =>
288
- event.runnerId !== runnerId &&
289
- ["completed", "failed", "timed_out", "cancelled", "waiting", "parked"].includes(
290
- event.type,
291
- ),
292
- );
293
- if (noteworthy.length === 0) {
294
- return;
282
+ runQueueStore.markWorkflowNotificationDelivered({
283
+ notificationId: notification.notificationId,
284
+ targetSessionId: sessionId,
285
+ claimToken,
286
+ });
295
287
  }
296
- const content = `Workflow run update:\n${noteworthy.map(describeRunEvent).join("\n")}`;
297
- pi.sendMessage(
298
- { customType: "pi-workflows-run-sync", content, display: false },
299
- { deliverAs: "steer", triggerTurn: false },
300
- );
301
- notify(ctx, noteworthy.map(describeRunEvent).join("; "));
302
288
  } catch {
303
- // Sync is observational.
304
- }
305
- };
306
-
307
- // The first-use catch-up: a snapshot of runs that need attention now.
308
- const sendStateSnapshot = async (ctx: ExtensionContext) => {
309
- if (runQueueStore === null) {
310
- return;
311
- }
312
- const lines: string[] = [];
313
- const rows = runQueueStore.listWorkflowRuns();
314
- for (const row of rows) {
315
- if (row.status === "parked") {
316
- lines.push(`${row.workflowName} run ${row.runId} is parked and will resume`);
317
- }
318
- }
319
- const known = new Set(rows.map((row) => row.runId));
320
- const continued = new Set(
321
- rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
322
- );
323
- const bundles = await listRunBundles(new WorkflowRunStore().outputRoot);
324
- for (const bundle of bundles) {
325
- if (
326
- bundle.state.status === "waiting" &&
327
- known.has(bundle.state.runId) &&
328
- !continued.has(bundle.state.runId)
329
- ) {
330
- lines.push(
331
- `${bundle.state.workflowName} run ${bundle.state.runId} waits at checkpoint ${bundle.state.waitingOn ?? "?"} — answer with /workflow answer`,
332
- );
333
- }
334
- }
335
- if (lines.length === 0) {
336
- return;
289
+ // Delivery retries on the next poll. It never affects workflow execution.
337
290
  }
338
- const content = `Workflow runs needing attention:\n${lines.join("\n")}`;
339
- pi.sendMessage(
340
- { customType: "pi-workflows-run-sync", content, display: false },
341
- { deliverAs: "steer", triggerTurn: false },
342
- );
343
- notify(ctx, lines.join("; "));
344
291
  };
345
292
 
346
293
  const startRunSync = (ctx: ExtensionContext) => {
@@ -368,9 +315,10 @@ export default function piWorkflows(pi: ExtensionAPI) {
368
315
  // Manual widget scroll: null follows the active node; a number is the
369
316
  // first visible graph row, set by shift+↑/↓ and reset on step advance.
370
317
  let widgetSource: WidgetSource | null = null;
371
- let widgetScroll: number | null = null;
318
+ let widgetScroll: WidgetScrollState = { graph: null, compact: null };
372
319
  let widgetShownScroll = 0;
373
320
  let widgetMaxScroll = 0;
321
+ let widgetLayout: WidgetLayout | null = null;
374
322
  let widgetStepCount = 0;
375
323
  let sessionClosed = false;
376
324
  let runGeneration = 0;
@@ -392,10 +340,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
392
340
  }
393
341
  };
394
342
 
395
- const setWidget = (ctx: ExtensionContext, lines: string[] | undefined) => {
343
+ const setWidget = (ctx: ExtensionContext, content: WorkflowWidgetContent | undefined) => {
396
344
  try {
397
345
  if (ctx.hasUI) {
398
- ctx.ui.setWidget(WIDGET_KEY, lines);
346
+ if (typeof content === "function") {
347
+ ctx.ui.setWidget(WIDGET_KEY, content);
348
+ } else {
349
+ ctx.ui.setWidget(WIDGET_KEY, content);
350
+ }
399
351
  }
400
352
  } catch {
401
353
  // Stale ctx; the widget no longer exists.
@@ -426,20 +378,30 @@ export default function piWorkflows(pi: ExtensionAPI) {
426
378
  if (!widgetSource) {
427
379
  return;
428
380
  }
429
- const held = runHeld();
430
- const view = buildWidgetView(
431
- widgetSource.state,
432
- widgetSource.snapshot,
433
- new Date(),
434
- widgetScroll,
435
- held,
436
- );
437
- widgetShownScroll = view.scroll;
438
- widgetMaxScroll = view.maxScroll;
439
- if (widgetScroll !== null) {
440
- widgetScroll = view.scroll;
381
+ const render = (width = Number.POSITIVE_INFINITY): string[] => {
382
+ if (!widgetSource) return [];
383
+ const view = buildWidgetView(
384
+ widgetSource.state,
385
+ widgetSource.snapshot,
386
+ new Date(),
387
+ widgetScroll,
388
+ runHeld(),
389
+ width,
390
+ );
391
+ widgetLayout = view.layout;
392
+ widgetShownScroll = view.scroll;
393
+ widgetMaxScroll = view.maxScroll;
394
+ if (widgetScroll[view.layout] !== null) {
395
+ widgetScroll[view.layout] = view.scroll;
396
+ }
397
+ return view.lines;
398
+ };
399
+ if (ctx.mode === "tui") {
400
+ setWidget(ctx, () => ({ render, invalidate() {} }));
401
+ } else {
402
+ // RPC transports string widgets but cannot serialize TUI component factories.
403
+ setWidget(ctx, render());
441
404
  }
442
- setWidget(ctx, view.lines);
443
405
  setStatus(ctx, footerStatus(widgetSource.state));
444
406
  };
445
407
 
@@ -451,7 +413,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
451
413
  if (state.steps.length !== widgetStepCount) {
452
414
  widgetStepCount = state.steps.length;
453
415
  // The workflow moved on; resume following the active node.
454
- widgetScroll = null;
416
+ widgetScroll = { graph: null, compact: null };
455
417
  }
456
418
  widgetSource = { state, snapshot };
457
419
  renderWidget(ctx);
@@ -459,7 +421,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
459
421
 
460
422
  const clearWidget = (ctx: ExtensionContext) => {
461
423
  widgetSource = null;
462
- widgetScroll = null;
424
+ widgetScroll = { graph: null, compact: null };
425
+ widgetLayout = null;
463
426
  setWidget(ctx, undefined);
464
427
  setStatus(ctx, undefined);
465
428
  };
@@ -468,7 +431,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
468
431
  if (!widgetSource || widgetMaxScroll === 0) {
469
432
  return;
470
433
  }
471
- widgetScroll = Math.max(0, Math.min(widgetShownScroll + delta, widgetMaxScroll));
434
+ if (widgetLayout === null) return;
435
+ widgetScroll[widgetLayout] = Math.max(0, Math.min(widgetShownScroll + delta, widgetMaxScroll));
472
436
  renderWidget(ctx);
473
437
  };
474
438
 
@@ -746,6 +710,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
746
710
  runnerId,
747
711
  claimToken: token,
748
712
  leaseMs: RUN_CLAIM_LEASE_MS,
713
+ originSessionId: ctx.sessionManager.getSessionId(),
749
714
  ...(options.parentRunId !== undefined ? { parentRunId: options.parentRunId } : {}),
750
715
  });
751
716
  claimToken = token;
@@ -798,6 +763,24 @@ export default function piWorkflows(pi: ExtensionAPI) {
798
763
  const engine = new WorkflowEngine({
799
764
  executor,
800
765
  store,
766
+ notificationSink: {
767
+ notify: (request) => {
768
+ fence?.();
769
+ if (queueStore === null) throw new Error("Workflow notifications require a queued run");
770
+ const record = queueStore.getWorkflowRun(request.runId);
771
+ if (record?.originSessionId === null || record?.originSessionId === undefined) {
772
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
773
+ }
774
+ const notification = queueStore.enqueueWorkflowNotification({
775
+ ...request,
776
+ targetSessionId: record.originSessionId,
777
+ });
778
+ return {
779
+ notificationId: notification.notificationId,
780
+ targetSessionId: notification.targetSessionId,
781
+ };
782
+ },
783
+ },
801
784
  // Awaited by the engine after run_started is persisted, so the session
802
785
  // binding and its trace event always precede node and terminal events.
803
786
  onRunStarted: async (runDir, state) => {
@@ -980,6 +963,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
980
963
  claimToken,
981
964
  leaseMs: RUN_CLAIM_LEASE_MS,
982
965
  excludeRunIds: [...migrationBlockedRuns],
966
+ sessionId: ctx.sessionManager.getSessionId(),
983
967
  });
984
968
  if (claimed === undefined) {
985
969
  return;
@@ -1291,7 +1275,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
1291
1275
  let parentRunId = requestedRunId ?? lastWaitingRunId;
1292
1276
  if (parentRunId === null) {
1293
1277
  const rows = ensureRunQueueStore(ctx.cwd).listWorkflowRuns();
1294
- const known = new Set(rows.map((row) => row.runId));
1278
+ const sessionId = ctx.sessionManager.getSessionId();
1279
+ const known = new Set(
1280
+ rows
1281
+ .filter((row) => row.originSessionId === null || row.originSessionId === sessionId)
1282
+ .map((row) => row.runId),
1283
+ );
1295
1284
  const continued = new Set(
1296
1285
  rows.map((row) => row.parentRunId).filter((parent): parent is string => parent !== null),
1297
1286
  );
@@ -1307,6 +1296,14 @@ export default function piWorkflows(pi: ExtensionAPI) {
1307
1296
  if (parentRunId === null) {
1308
1297
  throw new Error("No workflow is waiting for an answer.");
1309
1298
  }
1299
+ const queueRecord = ensureRunQueueStore(ctx.cwd).getWorkflowRun(parentRunId);
1300
+ if (
1301
+ queueRecord?.originSessionId !== null &&
1302
+ queueRecord?.originSessionId !== undefined &&
1303
+ queueRecord.originSessionId !== ctx.sessionManager.getSessionId()
1304
+ ) {
1305
+ throw new Error(`Workflow run ${parentRunId} belongs to another Pi session.`);
1306
+ }
1310
1307
  const parent = await readRunBundle(new WorkflowRunStore().runDirFor(parentRunId));
1311
1308
  if (
1312
1309
  parent === null ||
@@ -1816,7 +1813,8 @@ export default function piWorkflows(pi: ExtensionAPI) {
1816
1813
  clearWidgetTimer();
1817
1814
  stopWidgetTicker();
1818
1815
  widgetSource = null;
1819
- widgetScroll = null;
1816
+ widgetScroll = { graph: null, compact: null };
1817
+ widgetLayout = null;
1820
1818
  });
1821
1819
  }
1822
1820
 
@@ -1,3 +1,4 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
2
  import { ansi, stripAnsi } from "../render/ansi.js";
2
3
  import { renderGraphLines } from "../render/graph-render.js";
3
4
  import { sanitizeText } from "../workflows/text.js";
@@ -42,9 +43,14 @@ export function displayNodeIds(snapshot: WorkflowDefinitionSnapshot): string[] {
42
43
  return Object.keys(snapshot.nodes);
43
44
  }
44
45
 
46
+ export type WidgetLayout = "graph" | "compact";
47
+
48
+ export type WidgetScrollState = Record<WidgetLayout, number | null>;
49
+
45
50
  export type WidgetView = {
46
51
  lines: string[];
47
- /** The clamped first visible graph row; feed back in to scroll relatively. */
52
+ layout: WidgetLayout;
53
+ /** The clamped first visible row for the selected layout. */
48
54
  scroll: number;
49
55
  /** Largest useful scroll value; 0 when the whole graph fits. */
50
56
  maxScroll: number;
@@ -61,9 +67,13 @@ export function buildWidgetView(
61
67
  state: WorkflowRunState,
62
68
  snapshot: WorkflowDefinitionSnapshot,
63
69
  now: Date = new Date(),
64
- scroll: number | null = null,
70
+ scroll: WidgetScrollState = { graph: null, compact: null },
65
71
  held = false,
72
+ width = Number.POSITIVE_INFINITY,
66
73
  ): WidgetView {
74
+ const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
75
+ if (availableWidth === 0) return { lines: [], layout: "compact", scroll: 0, maxScroll: 0 };
76
+
67
77
  // `held` covers pauses the state cannot see yet: an escape-interrupted
68
78
  // step or a pause requested while the current node is still finishing.
69
79
  const paused = held || state.paused === true;
@@ -79,28 +89,94 @@ export function buildWidgetView(
79
89
  footer.push(` error: ${truncate(sanitizeText(state.error), 120)}`);
80
90
  }
81
91
  if (state.status === "waiting" && state.waitingOn) {
82
- footer.push(` waiting on checkpoint: ${state.waitingOn}`);
92
+ footer.push(` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`);
83
93
  }
84
94
 
85
95
  const budget = PI_MAX_WIDGET_LINES - 1 - footer.length;
86
96
  const graph = renderGraphLines({ state, snapshot }, state.steps.length - 1, now, {
87
97
  nodeStyle: "box",
88
98
  });
89
- if (graph.length === 0) {
99
+ if (graph.length > 0) {
100
+ const graphScroll = scroll.graph;
101
+ const windowed = windowLines(
102
+ graph,
103
+ budget,
104
+ graphScroll ?? focusLine(graph, state),
105
+ graphScroll !== null,
106
+ );
107
+ const graphLines = windowed.lines.map((line) => ` ${line}`);
108
+ if (graphLines.every((line) => visibleWidth(line) <= availableWidth)) {
109
+ return {
110
+ lines: fitLines([header, ...graphLines, ...footer], availableWidth),
111
+ layout: "graph",
112
+ scroll: windowed.scroll,
113
+ maxScroll: windowed.maxScroll,
114
+ };
115
+ }
116
+ }
117
+
118
+ return compactWidgetView(state, snapshot, header, footer, budget, availableWidth, scroll.compact);
119
+ }
120
+
121
+ function compactWidgetView(
122
+ state: WorkflowRunState,
123
+ snapshot: WorkflowDefinitionSnapshot,
124
+ header: string,
125
+ footer: string[],
126
+ budget: number,
127
+ width: number,
128
+ scroll: number | null,
129
+ ): WidgetView {
130
+ const nodes = displayNodeIds(snapshot).map((nodeId) => compactNodeLine(state, snapshot, nodeId));
131
+ if (nodes.length === 0) {
90
132
  return {
91
- lines: [header, ` ${compactNodeStrip(state, snapshot)}`, ...footer],
133
+ lines: fitLines([header, ...footer], width),
134
+ layout: "compact",
92
135
  scroll: 0,
93
136
  maxScroll: 0,
94
137
  };
95
138
  }
96
- const windowed = windowLines(graph, budget, scroll ?? focusLine(graph, state), scroll !== null);
139
+ const anchor = scroll ?? compactFocusIndex(state, snapshot);
140
+ const windowed = windowLines(nodes, budget, anchor, scroll !== null);
141
+ const indentation = width >= 3 ? " " : "";
97
142
  return {
98
- lines: [header, ...windowed.lines.map((line) => ` ${line}`), ...footer],
143
+ lines: fitLines(
144
+ [header, ...windowed.lines.map((line) => `${indentation}${line}`), ...footer],
145
+ width,
146
+ ),
147
+ layout: "compact",
99
148
  scroll: windowed.scroll,
100
149
  maxScroll: windowed.maxScroll,
101
150
  };
102
151
  }
103
152
 
153
+ function compactFocusIndex(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): number {
154
+ const nodeIds = displayNodeIds(snapshot);
155
+ const focused = state.currentNode ?? state.waitingOn;
156
+ if (focused === undefined) return Math.max(0, nodeIds.length - 1);
157
+ const index = nodeIds.indexOf(focused);
158
+ return index === -1 ? Math.max(0, nodeIds.length - 1) : index;
159
+ }
160
+
161
+ function compactNodeLine(
162
+ state: WorkflowRunState,
163
+ snapshot: WorkflowDefinitionSnapshot,
164
+ nodeId: string,
165
+ ): string {
166
+ const node = snapshot.nodes[nodeId];
167
+ const type = node?.nodeType === undefined ? "" : ` · ${node.nodeType}`;
168
+ const detail =
169
+ state.currentNode === nodeId && state.statusDetail
170
+ ? ` · ${sanitizeText(state.statusDetail)}`
171
+ : "";
172
+ return `${nodeGlyph(state, nodeId)} ${sanitizeText(nodeId)}${type}${detail}`;
173
+ }
174
+
175
+ function fitLines(lines: string[], width: number): string[] {
176
+ if (!Number.isFinite(width)) return lines;
177
+ return lines.map((line) => truncateToWidth(line, width, width > 1 ? "…" : ""));
178
+ }
179
+
104
180
  /** Back-compatible line view following the active node. */
105
181
  export function buildWidgetLines(
106
182
  state: WorkflowRunState,
@@ -167,19 +243,6 @@ function clampStart(anchor: number, inner: number, total: number, anchorIsStart:
167
243
  return Math.max(0, Math.min(start, total - inner));
168
244
  }
169
245
 
170
- function compactNodeStrip(state: WorkflowRunState, snapshot: WorkflowDefinitionSnapshot): string {
171
- return displayNodeIds(snapshot)
172
- .map((nodeId) => {
173
- const marker = nodeGlyph(state, nodeId);
174
- const detail =
175
- state.currentNode === nodeId && state.statusDetail
176
- ? ` (${sanitizeText(state.statusDetail)})`
177
- : "";
178
- return `${marker} ${nodeId}${detail}`;
179
- })
180
- .join(" ");
181
- }
182
-
183
246
  function truncate(text: string, maxLength: number): string {
184
247
  return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
185
248
  }
@@ -269,7 +269,26 @@ export class WorkflowHost {
269
269
  ...(this.options.piArgs !== undefined ? { piArgs: this.options.piArgs } : {}),
270
270
  ...(this.options.env !== undefined ? { env: this.options.env } : {}),
271
271
  });
272
- const engine = new WorkflowEngine({ executor, store: fencedStore });
272
+ const engine = new WorkflowEngine({
273
+ executor,
274
+ store: fencedStore,
275
+ notificationSink: {
276
+ notify: (request) => {
277
+ fence();
278
+ if (record.originSessionId === null) {
279
+ throw new Error(`Workflow run ${request.runId} has no origin session`);
280
+ }
281
+ const notification = store.enqueueWorkflowNotification({
282
+ ...request,
283
+ targetSessionId: record.originSessionId,
284
+ });
285
+ return {
286
+ notificationId: notification.notificationId,
287
+ targetSessionId: notification.targetSessionId,
288
+ };
289
+ },
290
+ },
291
+ });
273
292
  const parkEngine = () => engine.park();
274
293
  this.parkedEngines.push(parkEngine);
275
294
 
@@ -79,6 +79,7 @@ const CARD_DYNAMIC_RESERVE = "↻ 100 ◷ 9999d 23h 59m 59s";
79
79
  const NODE_TYPE_GLYPHS: Record<string, string> = {
80
80
  agent: "●",
81
81
  compute: "ƒ",
82
+ notify: "✉",
82
83
  action: "⚙",
83
84
  checkpoint: "◆",
84
85
  };
@@ -87,6 +88,8 @@ function nodeTypeStyle(nodeType: string): CanvasStyle {
87
88
  switch (nodeType) {
88
89
  case "agent":
89
90
  case "compute":
91
+ case "notify":
92
+ return nodeType === "notify" ? "action" : nodeType;
90
93
  case "action":
91
94
  case "checkpoint":
92
95
  return nodeType;
@@ -3,6 +3,7 @@ import {
3
3
  assertValidActionNode,
4
4
  assertValidCheckpointNode,
5
5
  assertValidComputeNode,
6
+ assertValidNotifyNode,
6
7
  assertValidShellActionNode,
7
8
  assertValidWorkflowDefinitionShape,
8
9
  } from "./schema.js";
@@ -12,6 +13,7 @@ import type {
12
13
  CheckpointNodeDefinition,
13
14
  ComputeNodeDefinition,
14
15
  FunctionActionNodeDefinition,
16
+ NotifyNodeDefinition,
15
17
  ShellActionNodeDefinition,
16
18
  WorkflowDefinition,
17
19
  } from "./types.js";
@@ -62,6 +64,15 @@ export function compute(
62
64
  return node;
63
65
  }
64
66
 
67
+ export function notify(definition: Omit<NotifyNodeDefinition, "nodeType">): NotifyNodeDefinition {
68
+ const node: NotifyNodeDefinition = {
69
+ nodeType: "notify",
70
+ ...definition,
71
+ };
72
+ assertValidNotifyNode(node);
73
+ return node;
74
+ }
75
+
65
76
  export function action(
66
77
  definition: Omit<FunctionActionNodeDefinition, "nodeType">,
67
78
  ): FunctionActionNodeDefinition;
@@ -30,6 +30,7 @@ import type {
30
30
  WorkflowNodeDefinition,
31
31
  WorkflowNodeOutcome,
32
32
  WorkflowNodeResult,
33
+ WorkflowNotificationSink,
33
34
  WorkflowRunResult,
34
35
  WorkflowRunState,
35
36
  WorkflowSource,
@@ -74,6 +75,7 @@ type NodeAttempt = {
74
75
  */
75
76
  export class WorkflowEngine {
76
77
  private readonly executor: AgentStepExecutor;
78
+ private readonly notificationSink: WorkflowNotificationSink | undefined;
77
79
  private readonly store: WorkflowRunStore;
78
80
  private readonly defaultNodeTimeoutMs: number;
79
81
  private readonly maxSteps: number;
@@ -88,6 +90,7 @@ export class WorkflowEngine {
88
90
 
89
91
  constructor(options: WorkflowEngineOptions) {
90
92
  this.executor = options.executor;
93
+ this.notificationSink = options.notificationSink;
91
94
  this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
92
95
  this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
93
96
  this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
@@ -868,6 +871,29 @@ export class WorkflowEngine {
868
871
  );
869
872
  case "compute":
870
873
  return { output: await node.run(context), promptText: null };
874
+ case "notify": {
875
+ if (this.notificationSink === undefined) {
876
+ throw new Error(`Workflow node ${nodeId} requires a notification sink`);
877
+ }
878
+ const content = await node.message(context);
879
+ if (typeof content !== "string" || content.trim().length === 0) {
880
+ throw new Error(`Workflow node ${nodeId} notification must be a non-empty string`);
881
+ }
882
+ const notificationIndex =
883
+ state.steps.filter(
884
+ (step) => step.nodeId === nodeId && step.nodeType === "notify" && step.outcome === "ok",
885
+ ).length + 1;
886
+ const receipt = await this.notificationSink.notify({
887
+ runId: state.runId,
888
+ workflowName: workflow.name,
889
+ nodeId,
890
+ attemptId,
891
+ notificationIndex,
892
+ kind: node.kind ?? "progress",
893
+ content: content.trim(),
894
+ });
895
+ return { output: receipt, promptText: null };
896
+ }
871
897
  case "action":
872
898
  return await this.runActionNode(node, context, signal, meta);
873
899
  case "checkpoint":