@particle-academy/fancy-flow 0.65.2 → 0.66.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 (105) hide show
  1. package/README.md +100 -3
  2. package/dist/{FlowViewer-BI_AyyvU.d.ts → FlowViewer-BCc_BvuD.d.ts} +1 -1
  3. package/dist/{FlowViewer-DABx-6A-.d.cts → FlowViewer-CoAJ3o52.d.cts} +1 -1
  4. package/dist/{HumanPrompt-Bpp3JiVH.d.cts → HumanPrompt--pXDOyYC.d.cts} +2 -2
  5. package/dist/{HumanPrompt-BKThiyjB.d.ts → HumanPrompt-DXm_FP9z.d.ts} +2 -2
  6. package/dist/chunk-2QXKDLGT.js +58 -0
  7. package/dist/chunk-2QXKDLGT.js.map +1 -0
  8. package/dist/{chunk-77V4QC6Y.js → chunk-B7HKJJVV.js} +3 -3
  9. package/dist/{chunk-77V4QC6Y.js.map → chunk-B7HKJJVV.js.map} +1 -1
  10. package/dist/{chunk-MBVX4ZRB.js → chunk-FNYLKSFJ.js} +3 -3
  11. package/dist/{chunk-MBVX4ZRB.js.map → chunk-FNYLKSFJ.js.map} +1 -1
  12. package/dist/{chunk-JF6WCRBU.js → chunk-HQGOFGAL.js} +861 -40
  13. package/dist/chunk-HQGOFGAL.js.map +1 -0
  14. package/dist/{chunk-5H54OTKT.js → chunk-IEYCNFXZ.js} +3 -3
  15. package/dist/{chunk-5H54OTKT.js.map → chunk-IEYCNFXZ.js.map} +1 -1
  16. package/dist/{chunk-W5DPKJY4.js → chunk-MTONBM53.js} +4 -4
  17. package/dist/chunk-MTONBM53.js.map +1 -0
  18. package/dist/{chunk-RIAFHQT5.js → chunk-PNHZHEWE.js} +3 -3
  19. package/dist/{chunk-RIAFHQT5.js.map → chunk-PNHZHEWE.js.map} +1 -1
  20. package/dist/{chunk-EO6444T2.js → chunk-UCCTXJXL.js} +4 -4
  21. package/dist/{chunk-EO6444T2.js.map → chunk-UCCTXJXL.js.map} +1 -1
  22. package/dist/connectors.d.cts +2 -2
  23. package/dist/connectors.d.ts +2 -2
  24. package/dist/durable/index.d.cts +2 -2
  25. package/dist/durable/index.d.ts +2 -2
  26. package/dist/durable.cjs +837 -47
  27. package/dist/durable.cjs.map +1 -1
  28. package/dist/durable.js +2 -3
  29. package/dist/durable.js.map +1 -1
  30. package/dist/engine.cjs +843 -132
  31. package/dist/engine.cjs.map +1 -1
  32. package/dist/engine.d.cts +6 -7
  33. package/dist/engine.d.ts +6 -7
  34. package/dist/engine.js +5 -6
  35. package/dist/engine.js.map +1 -1
  36. package/dist/fields/react-fancy.d.cts +3 -3
  37. package/dist/fields/react-fancy.d.ts +3 -3
  38. package/dist/index.cjs +853 -140
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +65 -36
  41. package/dist/index.d.ts +65 -36
  42. package/dist/index.js +19 -18
  43. package/dist/index.js.map +1 -1
  44. package/dist/layout/index.d.cts +1 -1
  45. package/dist/layout/index.d.ts +1 -1
  46. package/dist/llm/prism.cjs.map +1 -1
  47. package/dist/llm/prism.d.cts +1 -2
  48. package/dist/llm/prism.d.ts +1 -2
  49. package/dist/llm/prism.js +1 -1
  50. package/dist/llm/vercel-ai.cjs.map +1 -1
  51. package/dist/llm/vercel-ai.d.cts +1 -2
  52. package/dist/llm/vercel-ai.d.ts +1 -2
  53. package/dist/llm/vercel-ai.js +1 -1
  54. package/dist/registry/index.d.cts +5 -6
  55. package/dist/registry/index.d.ts +5 -6
  56. package/dist/{registry-CntAdUcY.d.cts → registry-BcgllSk2.d.cts} +1 -1
  57. package/dist/{registry-DYoV-MQi.d.ts → registry-h-2QswZ5.d.ts} +1 -1
  58. package/dist/registry.cjs +867 -37
  59. package/dist/registry.cjs.map +1 -1
  60. package/dist/registry.js +3 -3
  61. package/dist/{run-cohort-C1nRh_mi.d.ts → run-cohort-CjUBWg6X.d.ts} +2 -2
  62. package/dist/{run-cohort-CBRAUj-z.d.cts → run-cohort-DL9JzzPU.d.cts} +2 -2
  63. package/dist/{run-flow-2XsHBwpd.d.cts → run-flow-B_8hgO5_.d.cts} +1 -1
  64. package/dist/{run-flow-D7AOCcfE.d.ts → run-flow-CxEGBOxd.d.ts} +1 -1
  65. package/dist/runtime/index.d.cts +5 -5
  66. package/dist/runtime/index.d.ts +5 -5
  67. package/dist/runtime.cjs +837 -36
  68. package/dist/runtime.cjs.map +1 -1
  69. package/dist/runtime.js +4 -4
  70. package/dist/schema/index.d.cts +19 -6
  71. package/dist/schema/index.d.ts +19 -6
  72. package/dist/schema.cjs +837 -36
  73. package/dist/schema.cjs.map +1 -1
  74. package/dist/schema.js +4 -4
  75. package/dist/screens.cjs +837 -36
  76. package/dist/screens.cjs.map +1 -1
  77. package/dist/screens.d.cts +2 -2
  78. package/dist/screens.d.ts +2 -2
  79. package/dist/screens.js +5 -5
  80. package/dist/terminal/fancy-term-host.cjs +99 -0
  81. package/dist/terminal/fancy-term-host.cjs.map +1 -0
  82. package/dist/terminal/fancy-term-host.d.cts +76 -0
  83. package/dist/terminal/fancy-term-host.d.ts +76 -0
  84. package/dist/terminal/fancy-term-host.js +92 -0
  85. package/dist/terminal/fancy-term-host.js.map +1 -0
  86. package/dist/types-B-Syk9-M.d.cts +620 -0
  87. package/dist/types-B-Syk9-M.d.ts +620 -0
  88. package/dist/{types-D71SKA5A.d.cts → types-C2ATTmjN.d.cts} +1 -1
  89. package/dist/{types-Ckhz-YwC.d.ts → types-VunLGqrn.d.ts} +1 -1
  90. package/dist/ux.cjs +837 -36
  91. package/dist/ux.cjs.map +1 -1
  92. package/dist/ux.d.cts +2 -2
  93. package/dist/ux.d.ts +2 -2
  94. package/dist/ux.js +2 -2
  95. package/package.json +11 -1
  96. package/dist/capabilities-BYa5p5jw.d.cts +0 -112
  97. package/dist/capabilities-COOXRiNL.d.ts +0 -112
  98. package/dist/chunk-JF6WCRBU.js.map +0 -1
  99. package/dist/chunk-UM4C46AF.js +0 -103
  100. package/dist/chunk-UM4C46AF.js.map +0 -1
  101. package/dist/chunk-USL4FMFU.js +0 -41
  102. package/dist/chunk-USL4FMFU.js.map +0 -1
  103. package/dist/chunk-W5DPKJY4.js.map +0 -1
  104. package/dist/types-JFYjPJAG.d.cts +0 -333
  105. package/dist/types-JFYjPJAG.d.ts +0 -333
package/dist/index.cjs CHANGED
@@ -4314,9 +4314,9 @@ function transition_default() {
4314
4314
  // node_modules/d3-transition/src/transition/end.js
4315
4315
  function end_default() {
4316
4316
  var on0, on1, that = this, id2 = that._id, size = that.size();
4317
- return new Promise(function(resolve, reject) {
4317
+ return new Promise(function(resolve2, reject) {
4318
4318
  var cancel = { value: reject }, end = { value: function() {
4319
- if (--size === 0) resolve();
4319
+ if (--size === 0) resolve2();
4320
4320
  } };
4321
4321
  that.each(function() {
4322
4322
  var schedule = set2(this, id2), on2 = schedule.on;
@@ -4328,7 +4328,7 @@ function end_default() {
4328
4328
  }
4329
4329
  schedule.on = on1;
4330
4330
  });
4331
- if (size === 0) resolve();
4331
+ if (size === 0) resolve2();
4332
4332
  });
4333
4333
  }
4334
4334
 
@@ -5289,13 +5289,13 @@ function areSetsEqual(a, b) {
5289
5289
  return true;
5290
5290
  }
5291
5291
  function withResolvers() {
5292
- let resolve;
5292
+ let resolve2;
5293
5293
  let reject;
5294
5294
  const promise = new Promise((res, rej) => {
5295
- resolve = res;
5295
+ resolve2 = res;
5296
5296
  reject = rej;
5297
5297
  });
5298
- return { promise, resolve, reject };
5298
+ return { promise, resolve: resolve2, reject };
5299
5299
  }
5300
5300
  function mergeAriaLabelConfig(partial) {
5301
5301
  return { ...defaultAriaLabelConfig, ...partial || {} };
@@ -6967,8 +6967,8 @@ function XYPanZoom({ domNode, minZoom, maxZoom, translateExtent, viewport, onPan
6967
6967
  d3ZoomInstance.wheelDelta(wheelDelta);
6968
6968
  async function setTransform(transform2, options) {
6969
6969
  if (d3Selection) {
6970
- return new Promise((resolve) => {
6971
- d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).transform(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve(true)), transform2);
6970
+ return new Promise((resolve2) => {
6971
+ d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).transform(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve2(true)), transform2);
6972
6972
  });
6973
6973
  }
6974
6974
  return false;
@@ -7070,16 +7070,16 @@ function XYPanZoom({ domNode, minZoom, maxZoom, translateExtent, viewport, onPan
7070
7070
  }
7071
7071
  async function scaleTo(zoom, options) {
7072
7072
  if (d3Selection) {
7073
- return new Promise((resolve) => {
7074
- d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).scaleTo(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve(true)), zoom);
7073
+ return new Promise((resolve2) => {
7074
+ d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).scaleTo(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve2(true)), zoom);
7075
7075
  });
7076
7076
  }
7077
7077
  return false;
7078
7078
  }
7079
7079
  async function scaleBy(factor, options) {
7080
7080
  if (d3Selection) {
7081
- return new Promise((resolve) => {
7082
- d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).scaleBy(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve(true)), factor);
7081
+ return new Promise((resolve2) => {
7082
+ d3ZoomInstance?.interpolate(options?.interpolate === "linear" ? value_default : zoom_default).scaleBy(getD3Transition(d3Selection, options?.duration, options?.ease, () => resolve2(true)), factor);
7083
7083
  });
7084
7084
  }
7085
7085
  return false;
@@ -10944,11 +10944,11 @@ function NoteNodeInner(props) {
10944
10944
  const config = data.config ?? {};
10945
10945
  const api = useFlowEditorOptional();
10946
10946
  const hasConfigText = typeof config.text === "string" && config.text.length > 0;
10947
- const text2 = hasConfigText ? String(config.text) : typeof data.body === "string" ? data.body : typeof data.text === "string" ? data.text : "";
10947
+ const text3 = hasConfigText ? String(config.text) : typeof data.body === "string" ? data.body : typeof data.text === "string" ? data.text : "";
10948
10948
  const title = typeof config.title === "string" && config.title || (!hasConfigText && typeof data.body === "string" && typeof data.label === "string" ? data.label : "");
10949
10949
  const skin = NOTE_COLORS[coerceColor(config.color)];
10950
10950
  const [editing, setEditing] = ReactExports.useState(false);
10951
- const [draft, setDraft] = ReactExports.useState(text2);
10951
+ const [draft, setDraft] = ReactExports.useState(text3);
10952
10952
  const areaRef = ReactExports.useRef(null);
10953
10953
  ReactExports.useEffect(() => {
10954
10954
  if (editing && areaRef.current) {
@@ -10959,7 +10959,7 @@ function NoteNodeInner(props) {
10959
10959
  }, [editing]);
10960
10960
  const commit = () => {
10961
10961
  setEditing(false);
10962
- if (!api || draft === text2) return;
10962
+ if (!api || draft === text3) return;
10963
10963
  const current = api.nodes.find((n) => n.id === props.id);
10964
10964
  if (!current) return;
10965
10965
  api.updateNode({
@@ -10973,7 +10973,7 @@ function NoteNodeInner(props) {
10973
10973
  className: ["ff-note", props.selected ? "ff-note--selected" : ""].filter(Boolean).join(" "),
10974
10974
  style: { background: skin.bg, borderColor: skin.border },
10975
10975
  onDoubleClick: api ? () => {
10976
- setDraft(text2);
10976
+ setDraft(text3);
10977
10977
  setEditing(true);
10978
10978
  } : void 0,
10979
10979
  children: [
@@ -10992,7 +10992,7 @@ function NoteNodeInner(props) {
10992
10992
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) commit();
10993
10993
  }
10994
10994
  }
10995
- ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "ff-note__body", children: text2 || /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ff-note__placeholder", children: api ? "Double-click to write a note\u2026" : "Note" }) })
10995
+ ) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "ff-note__body", children: text3 || /* @__PURE__ */ jsxRuntime.jsx("span", { className: "ff-note__placeholder", children: api ? "Double-click to write a note\u2026" : "Note" }) })
10996
10996
  ]
10997
10997
  }
10998
10998
  );
@@ -11193,6 +11193,239 @@ var RunIdentity = class _RunIdentity {
11193
11193
  }
11194
11194
  };
11195
11195
 
11196
+ // src/runtime/terminal-transcript.ts
11197
+ var ESC = String.fromCharCode(27);
11198
+ var CSI = String.fromCharCode(155);
11199
+ var BEL = String.fromCharCode(7);
11200
+ var ANSI = new RegExp(
11201
+ `[${ESC}${CSI}](?:\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)|[@-Z\\\\-_])`,
11202
+ "g"
11203
+ );
11204
+ var MAX_PENDING_ESCAPE = 1024;
11205
+ var MAX_BUFFER = 1e6;
11206
+ var TerminalTranscript = class {
11207
+ constructor() {
11208
+ /** Stripped, complete text not yet consumed by a wait. */
11209
+ this.text = "";
11210
+ /** Raw bytes held back because they may be the start of an escape sequence. */
11211
+ this.tail = "";
11212
+ this.waiters = /* @__PURE__ */ new Set();
11213
+ }
11214
+ /** Feed raw terminal output in. Safe to call with any chunking. */
11215
+ append(chunk) {
11216
+ this.tail += chunk;
11217
+ const safe = this.safeLength();
11218
+ if (safe > 0) {
11219
+ this.text += this.tail.slice(0, safe).replace(ANSI, "");
11220
+ this.tail = this.tail.slice(safe);
11221
+ }
11222
+ if (this.text.length > MAX_BUFFER) {
11223
+ this.text = this.text.slice(this.text.length - MAX_BUFFER);
11224
+ }
11225
+ for (const wake2 of [...this.waiters]) wake2();
11226
+ }
11227
+ /**
11228
+ * How much of `tail` can be stripped now.
11229
+ *
11230
+ * Everything, unless the final `ESC` has not yet been terminated — in which
11231
+ * case processing stops there and resumes when the rest arrives.
11232
+ */
11233
+ safeLength() {
11234
+ const lastEsc = Math.max(this.tail.lastIndexOf(ESC), this.tail.lastIndexOf(CSI));
11235
+ if (lastEsc === -1) return this.tail.length;
11236
+ ANSI.lastIndex = lastEsc;
11237
+ const match = ANSI.exec(this.tail);
11238
+ ANSI.lastIndex = 0;
11239
+ if (match && match.index === lastEsc) return this.tail.length;
11240
+ if (this.tail.length - lastEsc > MAX_PENDING_ESCAPE) return this.tail.length;
11241
+ return lastEsc;
11242
+ }
11243
+ /** Unconsumed output, escape sequences removed. */
11244
+ peek() {
11245
+ return this.text;
11246
+ }
11247
+ /** Drop everything currently buffered — used before typing a new command. */
11248
+ clear() {
11249
+ this.text = "";
11250
+ this.tail = "";
11251
+ }
11252
+ /**
11253
+ * Wait until `pattern` matches the unconsumed text.
11254
+ *
11255
+ * Checks what has ALREADY arrived before subscribing, because the common case
11256
+ * is that the process answered while the previous node was still finishing.
11257
+ *
11258
+ * `exited` is raced deliberately. Without it, a shell that dies reports as
11259
+ * "timed out waiting for X" — sending whoever reads it to lengthen a timeout
11260
+ * for a process that is not running. Naming the exit is the difference
11261
+ * between a diagnosis and a wrong lead.
11262
+ */
11263
+ waitFor(pattern, options) {
11264
+ return new Promise((resolve2) => {
11265
+ let settled = false;
11266
+ let timer2;
11267
+ const finish = (result) => {
11268
+ if (settled) return;
11269
+ settled = true;
11270
+ this.waiters.delete(check);
11271
+ if (timer2) clearTimeout(timer2);
11272
+ resolve2(result);
11273
+ };
11274
+ const check = () => {
11275
+ pattern.lastIndex = 0;
11276
+ const match = pattern.exec(this.text);
11277
+ if (!match) return;
11278
+ const through = match.index + (match[0].length || 1);
11279
+ const text3 = this.text.slice(0, through);
11280
+ this.text = this.text.slice(through);
11281
+ finish({ status: "matched", text: text3, match });
11282
+ };
11283
+ this.waiters.add(check);
11284
+ check();
11285
+ if (settled) return;
11286
+ if (options.timeoutMs > 0) {
11287
+ timer2 = setTimeout(() => {
11288
+ const text3 = this.text;
11289
+ this.text = "";
11290
+ finish({ status: "timeout", text: text3 });
11291
+ }, options.timeoutMs);
11292
+ timer2.unref?.();
11293
+ }
11294
+ options.exited?.then(
11295
+ (exit) => {
11296
+ if (settled) return;
11297
+ const text3 = this.text;
11298
+ this.text = "";
11299
+ finish({ status: "exited", text: text3, exitCode: exit.exitCode, signal: exit.signal });
11300
+ },
11301
+ () => {
11302
+ }
11303
+ );
11304
+ });
11305
+ }
11306
+ };
11307
+
11308
+ // src/runtime/terminal-sessions.ts
11309
+ var TerminalSessions = class {
11310
+ constructor(graph) {
11311
+ this.graph = graph;
11312
+ this.open = /* @__PURE__ */ new Map();
11313
+ this.laneOf = /* @__PURE__ */ new Map();
11314
+ this.transcripts = /* @__PURE__ */ new Map();
11315
+ this.listening = /* @__PURE__ */ new Map();
11316
+ }
11317
+ /**
11318
+ * The terminal lane a node belongs to, or null.
11319
+ *
11320
+ * Walks up `parentId` rather than checking only the immediate parent, and
11321
+ * caches per node id — a graph is walked once per run, and the answer cannot
11322
+ * change while it runs.
11323
+ */
11324
+ laneFor(nodeId, isTerminalLane) {
11325
+ const cached = this.laneOf.get(nodeId);
11326
+ if (cached !== void 0) return cached;
11327
+ const byId = new Map(this.graph.nodes.map((n) => [n.id, n]));
11328
+ const seen = /* @__PURE__ */ new Set();
11329
+ let current = byId.get(nodeId);
11330
+ let answer = null;
11331
+ while (current) {
11332
+ if (seen.has(current.id)) break;
11333
+ seen.add(current.id);
11334
+ if (current.id !== nodeId && isTerminalLane(current)) {
11335
+ answer = current.id;
11336
+ break;
11337
+ }
11338
+ const parentId = current.parentId;
11339
+ current = parentId ? byId.get(parentId) : void 0;
11340
+ }
11341
+ this.laneOf.set(nodeId, answer);
11342
+ return answer;
11343
+ }
11344
+ /**
11345
+ * The session for a lane, opening it on first use.
11346
+ *
11347
+ * The PROMISE is cached, not the resolved session. Two nodes cannot run
11348
+ * concurrently in the current engine, but a caller that awaits a
11349
+ * half-finished open would otherwise start a second process — and two shells
11350
+ * where a graph says one is the failure this class exists to prevent, arriving
11351
+ * only under concurrency and therefore only sometimes.
11352
+ */
11353
+ session(laneId, spec) {
11354
+ const existing = this.open.get(laneId);
11355
+ if (existing) return existing;
11356
+ {
11357
+ const failed = Promise.reject(
11358
+ new Error(
11359
+ "No terminal host is registered. A terminal lane needs one \u2014 call registerTerminalHost() from the desktop app, or install @particle-academy/fancy-flow/terminal/fancy-term-host."
11360
+ )
11361
+ );
11362
+ this.open.set(laneId, failed);
11363
+ failed.catch(() => {
11364
+ });
11365
+ return failed;
11366
+ }
11367
+ }
11368
+ /**
11369
+ * The accumulated output for a lane.
11370
+ *
11371
+ * Created on demand and kept for the run, so it exists before the session
11372
+ * resolves and survives every node that reads it. Consuming is the reader's
11373
+ * job — see `TerminalTranscript.waitFor`.
11374
+ */
11375
+ transcriptFor(laneId) {
11376
+ const existing = this.transcripts.get(laneId);
11377
+ if (existing) return existing;
11378
+ const created = new TerminalTranscript();
11379
+ this.transcripts.set(laneId, created);
11380
+ return created;
11381
+ }
11382
+ /** True once a lane has a session — used to avoid opening one during teardown. */
11383
+ isOpen(laneId) {
11384
+ return this.open.has(laneId);
11385
+ }
11386
+ /**
11387
+ * Close every session this run opened.
11388
+ *
11389
+ * Every close is attempted even if one throws: a host that fails to close one
11390
+ * PTY must not strand the others, which would leave processes alive after the
11391
+ * run reported that it had finished. Errors are returned rather than thrown,
11392
+ * because teardown runs in a `finally` and throwing there would replace the
11393
+ * run's real error with a cleanup error.
11394
+ */
11395
+ async closeAll() {
11396
+ const errors = [];
11397
+ for (const [laneId, pending] of this.open) {
11398
+ try {
11399
+ this.listening.get(laneId)?.();
11400
+ this.listening.delete(laneId);
11401
+ const session = await pending;
11402
+ await session.close();
11403
+ } catch (e) {
11404
+ errors.push(e instanceof Error ? e : new Error(`${laneId}: ${String(e)}`));
11405
+ }
11406
+ }
11407
+ this.open.clear();
11408
+ this.listening.clear();
11409
+ this.transcripts.clear();
11410
+ return errors;
11411
+ }
11412
+ };
11413
+ function specForLane(lane) {
11414
+ const config = lane.data?.config ?? {};
11415
+ const text3 = (key) => {
11416
+ const value = config[key];
11417
+ return typeof value === "string" && value !== "" ? value : void 0;
11418
+ };
11419
+ const env = config.env;
11420
+ return {
11421
+ command: text3("command"),
11422
+ cwd: text3("cwd"),
11423
+ env: env && typeof env === "object" && !Array.isArray(env) ? Object.fromEntries(
11424
+ Object.entries(env).map(([k2, v2]) => [k2, String(v2)])
11425
+ ) : void 0
11426
+ };
11427
+ }
11428
+
11196
11429
  // src/runtime/workflow-props.ts
11197
11430
  function typeOf(value) {
11198
11431
  if (value === null) return "null";
@@ -11271,6 +11504,25 @@ async function runFlow(graph, executors, onEvent = () => {
11271
11504
  const props = propsCheck.props;
11272
11505
  const declaresProps = (graph.inputs?.length ?? 0) > 0;
11273
11506
  const incomingByNode = indexIncoming(graph.edges);
11507
+ const sessions = new TerminalSessions(graph);
11508
+ const isTerminalLane = (candidate) => getNodeKind(candidate.type ?? "")?.name === "@particle-academy/terminal_lane";
11509
+ const terminalAccessorFor = (node) => {
11510
+ const laneId = sessions.laneFor(node.id, isTerminalLane);
11511
+ if (laneId === null) return void 0;
11512
+ const lane = graph.nodes.find((n) => n.id === laneId);
11513
+ if (!lane) return void 0;
11514
+ return {
11515
+ session: () => sessions.session(laneId, specForLane(lane)),
11516
+ // Awaits the session first on purpose. A transcript handed out before
11517
+ // anything is feeding it would look perfectly healthy and match nothing,
11518
+ // and "waited and nothing came" is the hardest failure here to tell apart
11519
+ // from a process that is simply slow.
11520
+ transcript: async () => {
11521
+ await sessions.session(laneId, specForLane(lane));
11522
+ return sessions.transcriptFor(laneId);
11523
+ }
11524
+ };
11525
+ };
11274
11526
  const timer2 = timeoutMs ? setTimeout(() => errors.push(`Run timed out after ${timeoutMs}ms`), timeoutMs) : null;
11275
11527
  onEvent({ type: "run-start" });
11276
11528
  try {
@@ -11336,7 +11588,8 @@ async function runFlow(graph, executors, onEvent = () => {
11336
11588
  emit: onEvent,
11337
11589
  executors,
11338
11590
  depth,
11339
- run
11591
+ run,
11592
+ terminal: terminalAccessorFor(node)
11340
11593
  })
11341
11594
  );
11342
11595
  outputs[node.id] = result;
@@ -11358,6 +11611,9 @@ async function runFlow(graph, executors, onEvent = () => {
11358
11611
  }
11359
11612
  } finally {
11360
11613
  if (timer2) clearTimeout(timer2);
11614
+ for (const error of await sessions.closeAll()) {
11615
+ onEvent({ type: "log", level: "warn", message: `terminal close failed: ${error.message}` });
11616
+ }
11361
11617
  }
11362
11618
  const ok = errors.length === 0;
11363
11619
  onEvent({ type: "run-end", ok });
@@ -11436,7 +11692,7 @@ function pickExecutor(executors, node) {
11436
11692
  for (const id2 of executorLookupIds(node)) {
11437
11693
  if (executors[id2]) return executors[id2];
11438
11694
  }
11439
- return void 0;
11695
+ return getNodeKind(node.type ?? "")?.executor;
11440
11696
  }
11441
11697
  function activatedPorts(node, result) {
11442
11698
  if (result && typeof result === "object") {
@@ -11546,6 +11802,376 @@ var subflowExecutor = async (ctx) => {
11546
11802
  return { __port: "out", value: result.outputs };
11547
11803
  };
11548
11804
 
11805
+ // src/expressions/expr.ts
11806
+ function wholeExpression(trimmed) {
11807
+ if (trimmed.length < 4) return null;
11808
+ if (!trimmed.startsWith("{{") || !trimmed.endsWith("}}")) return null;
11809
+ return trimmed.slice(2, -2);
11810
+ }
11811
+ function interpolate(template, resolve2) {
11812
+ let out = "";
11813
+ let i = 0;
11814
+ for (; ; ) {
11815
+ const open = template.indexOf("{{", i);
11816
+ if (open === -1) return out + template.slice(i);
11817
+ const close = template.indexOf("}}", open + 2);
11818
+ if (close === -1) return out + template.slice(i);
11819
+ out += template.slice(i, open) + resolve2(template.slice(open + 2, close));
11820
+ i = close + 2;
11821
+ }
11822
+ }
11823
+ var FALSY_STRINGS = /* @__PURE__ */ new Set(["", "0", "false", "no", "off", "null"]);
11824
+ var UnresolvedPathError = class extends Error {
11825
+ constructor(path) {
11826
+ super(
11827
+ `Expression path "${path}" did not resolve. Under the "throw" policy an unresolvable path is an error rather than an empty string.`
11828
+ );
11829
+ this.path = path;
11830
+ this.name = "UnresolvedPathError";
11831
+ }
11832
+ };
11833
+ function tryResolvePath(path, context) {
11834
+ const unresolved = { resolved: false, value: null };
11835
+ const trimmed = path.trim();
11836
+ if (trimmed === "") return unresolved;
11837
+ const segments = trimmed.split(".");
11838
+ let cursor;
11839
+ const head = segments[0];
11840
+ if (head === "$json" || head === "$input") {
11841
+ cursor = context !== null && typeof context === "object" && "in" in context ? context.in : context;
11842
+ segments.shift();
11843
+ } else {
11844
+ cursor = context;
11845
+ }
11846
+ for (const segment of segments) {
11847
+ if (cursor === null || cursor === void 0) return unresolved;
11848
+ if (typeof cursor !== "object") return unresolved;
11849
+ const next = cursor[segment];
11850
+ if (next === void 0) return unresolved;
11851
+ cursor = next;
11852
+ }
11853
+ return { resolved: true, value: cursor === void 0 ? null : cursor };
11854
+ }
11855
+ function resolvePath(path, context) {
11856
+ return tryResolvePath(path, context).value;
11857
+ }
11858
+ function evaluateExpression(template, context, options = {}) {
11859
+ if (typeof template !== "string") return template;
11860
+ const policy = options.onUnresolved ?? "empty";
11861
+ const whole = wholeExpression(template.trim());
11862
+ if (whole !== null) {
11863
+ const r = tryResolvePath(whole, context);
11864
+ if (r.resolved) return r.value;
11865
+ if (policy === "throw") throw new UnresolvedPathError(whole);
11866
+ return policy === "keep" ? template : null;
11867
+ }
11868
+ return interpolate(template, (path) => {
11869
+ const r = tryResolvePath(path, context);
11870
+ if (r.resolved) return stringify(r.value);
11871
+ if (policy === "throw") throw new UnresolvedPathError(path);
11872
+ return policy === "keep" ? `{{${path}}}` : "";
11873
+ });
11874
+ }
11875
+ function truthy(value) {
11876
+ if (typeof value === "boolean") return value;
11877
+ if (value === null || value === void 0) return false;
11878
+ if (typeof value === "string") return !FALSY_STRINGS.has(value.trim().toLowerCase());
11879
+ if (Array.isArray(value)) return value.length > 0;
11880
+ if (typeof value === "number") return value !== 0;
11881
+ return Boolean(value);
11882
+ }
11883
+ function text(value) {
11884
+ return stringify(value);
11885
+ }
11886
+ function stringify(value) {
11887
+ if (typeof value === "string") return value;
11888
+ if (typeof value === "boolean") return value ? "true" : "false";
11889
+ if (value === null || value === void 0) return "";
11890
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
11891
+ try {
11892
+ return JSON.stringify(value) ?? "";
11893
+ } catch {
11894
+ return "";
11895
+ }
11896
+ }
11897
+ function evaluateConfig(config, context, options = {}) {
11898
+ const out = {};
11899
+ for (const [key, value] of Object.entries(config)) {
11900
+ out[key] = Array.isArray(value) ? value.map((v2) => evaluateExpression(v2, context, options)) : value !== null && typeof value === "object" ? evaluateConfig(value, context, options) : evaluateExpression(value, context, options);
11901
+ }
11902
+ return out;
11903
+ }
11904
+
11905
+ // src/registry/logic.ts
11906
+ function configOf(node) {
11907
+ return node?.data?.config ?? {};
11908
+ }
11909
+ function contextOf(inputs) {
11910
+ return inputs;
11911
+ }
11912
+ function resolve(value, inputs) {
11913
+ if (typeof value !== "string") return value;
11914
+ return evaluateExpression(value, contextOf(inputs));
11915
+ }
11916
+ function conditionHolds(row, inputs) {
11917
+ const left = resolve(row.left, inputs);
11918
+ const right = resolve(row.right, inputs);
11919
+ const operator = typeof row.operator === "string" ? row.operator : "eq";
11920
+ const asText = (v2) => text(v2);
11921
+ const asNumber = (v2) => {
11922
+ const n = typeof v2 === "number" ? v2 : Number(asText(v2));
11923
+ return Number.isFinite(n) ? n : NaN;
11924
+ };
11925
+ const numeric = (fn2) => {
11926
+ const a = asNumber(left);
11927
+ const b = asNumber(right);
11928
+ return Number.isFinite(a) && Number.isFinite(b) ? fn2(a, b) : false;
11929
+ };
11930
+ const isEmpty = (v2) => v2 === null || v2 === void 0 || v2 === "" || Array.isArray(v2) && v2.length === 0 || typeof v2 === "object" && !Array.isArray(v2) && Object.keys(v2).length === 0;
11931
+ switch (operator) {
11932
+ // Compared as TEXT, deliberately. `right` is a text field in the schema, so
11933
+ // `status == "2"` must match the number 2 an upstream node produced —
11934
+ // strict equality there would fail on a difference the author cannot see.
11935
+ case "eq":
11936
+ return asText(left) === asText(right);
11937
+ case "neq":
11938
+ return asText(left) !== asText(right);
11939
+ case "contains":
11940
+ return asText(left).includes(asText(right));
11941
+ case "not_contains":
11942
+ return !asText(left).includes(asText(right));
11943
+ case "gt":
11944
+ return numeric((a, b) => a > b);
11945
+ case "gte":
11946
+ return numeric((a, b) => a >= b);
11947
+ case "lt":
11948
+ return numeric((a, b) => a < b);
11949
+ case "lte":
11950
+ return numeric((a, b) => a <= b);
11951
+ case "truthy":
11952
+ return truthy(left);
11953
+ case "falsy":
11954
+ return !truthy(left);
11955
+ case "empty":
11956
+ return isEmpty(left);
11957
+ case "not_empty":
11958
+ return !isEmpty(left);
11959
+ default:
11960
+ return false;
11961
+ }
11962
+ }
11963
+ var branchExecutor = (ctx) => {
11964
+ const config = configOf(ctx.node);
11965
+ const inputs = ctx.inputs;
11966
+ let taken;
11967
+ const raw = config.condition;
11968
+ if (typeof raw === "string" && raw.trim() !== "") {
11969
+ taken = truthy(resolve(raw, inputs));
11970
+ } else {
11971
+ const rows = Array.isArray(config.conditions) ? config.conditions : [];
11972
+ if (rows.length === 0) {
11973
+ taken = false;
11974
+ } else {
11975
+ const results = rows.map((row) => conditionHolds(row, inputs));
11976
+ taken = config.match === "any" ? results.some(Boolean) : results.every(Boolean);
11977
+ }
11978
+ }
11979
+ return { __port: taken ? "true" : "false", value: ctx.inputs.in ?? ctx.inputs };
11980
+ };
11981
+ var transformExecutor = (ctx) => {
11982
+ const config = configOf(ctx.node);
11983
+ const inputs = ctx.inputs;
11984
+ const passthrough = ctx.inputs.in ?? ctx.inputs;
11985
+ if (config.mode === "expression") {
11986
+ const expression = config.expression;
11987
+ if (typeof expression !== "string" || expression.trim() === "") return passthrough;
11988
+ return resolve(expression, inputs);
11989
+ }
11990
+ const rows = Array.isArray(config.fields) ? config.fields : [];
11991
+ if (rows.length === 0) return passthrough;
11992
+ const out = {};
11993
+ let wrote = false;
11994
+ for (const row of rows) {
11995
+ const key = typeof row.key === "string" ? row.key.trim() : "";
11996
+ if (key === "") continue;
11997
+ out[key] = resolve(row.value, inputs);
11998
+ wrote = true;
11999
+ }
12000
+ return wrote ? out : passthrough;
12001
+ };
12002
+ var mergeExecutor = (ctx) => {
12003
+ const entries = Object.entries(ctx.inputs);
12004
+ if (configOf(ctx.node).mode === "concat") {
12005
+ const out = [];
12006
+ for (const [, value] of entries) {
12007
+ if (value === null || value === void 0) continue;
12008
+ if (Array.isArray(value)) out.push(...value);
12009
+ else out.push(value);
12010
+ }
12011
+ return out;
12012
+ }
12013
+ const merged = {};
12014
+ for (const [port, value] of entries) {
12015
+ if (value === null || value === void 0) continue;
12016
+ if (typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
12017
+ else merged[port] = value;
12018
+ }
12019
+ return merged;
12020
+ };
12021
+ var forEachExecutor = (ctx) => {
12022
+ const source = resolve(configOf(ctx.node).source, ctx.inputs);
12023
+ let items;
12024
+ if (Array.isArray(source)) items = source;
12025
+ else if (source === null || source === void 0) items = [];
12026
+ else if (typeof source === "object") items = Object.values(source);
12027
+ else items = [source];
12028
+ return { items, count: items.length };
12029
+ };
12030
+
12031
+ // src/registry/terminal-nodes.ts
12032
+ var DEFAULT_TIMEOUT_MS = 12e4;
12033
+ var ENTER = "\r";
12034
+ function configOf2(node) {
12035
+ return node?.data?.config ?? {};
12036
+ }
12037
+ function text2(config, key) {
12038
+ const value = config[key];
12039
+ return typeof value === "string" ? value : "";
12040
+ }
12041
+ function millis(config, key, fallback) {
12042
+ const value = Number(config[key]);
12043
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
12044
+ }
12045
+ function exitMarker(nonce) {
12046
+ const token = `__fancy_flow_exit_${nonce}__`;
12047
+ return {
12048
+ token,
12049
+ // The expanded status is required to be digits. The shell ECHOES the typed
12050
+ // command back first, and that echo contains the marker with `$?`
12051
+ // unexpanded — so requiring digits is what stops the node matching its own
12052
+ // command line and reporting success before anything has run.
12053
+ pattern: new RegExp(`${token}:(\\d+)`)
12054
+ };
12055
+ }
12056
+ var counter = 0;
12057
+ function newNonce() {
12058
+ counter += 1;
12059
+ return `${Date.now().toString(36)}${counter.toString(36)}${Math.random().toString(36).slice(2, 8)}`;
12060
+ }
12061
+ function withoutMarker(output, token) {
12062
+ return output.split("\n").filter((line) => !line.includes(token)).join("\n").trim();
12063
+ }
12064
+ function fail(ctx, message) {
12065
+ ctx.abort(message);
12066
+ throw new Error(message);
12067
+ }
12068
+ function requireTerminal(ctx) {
12069
+ if (!ctx.terminal) {
12070
+ fail(
12071
+ ctx,
12072
+ `"${String(ctx.node.data?.label ?? ctx.node.id)}" is a terminal node but is not inside a terminal lane. Drag it into one \u2014 a terminal node outside a lane has no session to talk to, and opening a private shell for it would defeat the point of the lane.`
12073
+ );
12074
+ }
12075
+ return ctx.terminal;
12076
+ }
12077
+ function compilePattern(ctx, raw, config) {
12078
+ const flags = typeof config.flags === "string" ? config.flags.replace(/[gy]/g, "") : "";
12079
+ try {
12080
+ return config.mode === "regex" ? new RegExp(raw, flags) : new RegExp(raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), flags);
12081
+ } catch (e) {
12082
+ return fail(ctx, `terminal_await has an invalid regex: ${e instanceof Error ? e.message : String(e)}`);
12083
+ }
12084
+ }
12085
+ var terminalRunExecutor = async (ctx) => {
12086
+ const config = configOf2(ctx.node);
12087
+ const command = text2(config, "command").trim();
12088
+ if (!command) fail(ctx, "terminal_run has no command configured");
12089
+ const terminal = requireTerminal(ctx);
12090
+ const session = await terminal.session();
12091
+ const transcript = await terminal.transcript();
12092
+ const { token, pattern } = exitMarker(newNonce());
12093
+ const timeoutMs = millis(config, "timeoutMs", DEFAULT_TIMEOUT_MS);
12094
+ transcript.clear();
12095
+ await session.write(`${command}; printf '${token}:%s\\n' "$?"${ENTER}`);
12096
+ const result = await transcript.waitFor(pattern, { timeoutMs, exited: session.exited });
12097
+ if (result.status === "exited") {
12098
+ fail(
12099
+ ctx,
12100
+ `The terminal exited (code ${result.exitCode}${result.signal ? `, signal ${result.signal}` : ""}) while running "${command}". The lane's session is gone, so every later node in this lane would fail too.`
12101
+ );
12102
+ }
12103
+ if (result.status === "timeout") {
12104
+ fail(
12105
+ ctx,
12106
+ `"${command}" did not finish within ${timeoutMs}ms. Raise the node's timeout if it is genuinely slow \u2014 but a command that waits for input never finishes at all, and needs terminal_send + terminal_await.`
12107
+ );
12108
+ }
12109
+ const exitCode = Number(result.match[1]);
12110
+ const output = withoutMarker(result.text, token);
12111
+ const failOnNonZero = config.failOnNonZero !== false;
12112
+ if (failOnNonZero && exitCode !== 0) {
12113
+ ctx.abort(`"${command}" exited ${exitCode}.
12114
+ ${output}`);
12115
+ }
12116
+ return { output, exitCode, command };
12117
+ };
12118
+ var terminalSendExecutor = async (ctx) => {
12119
+ const config = configOf2(ctx.node);
12120
+ const terminal = requireTerminal(ctx);
12121
+ const session = await terminal.session();
12122
+ const body = text2(config, "text");
12123
+ const submit = config.submit !== false;
12124
+ if (config.clearFirst === true) {
12125
+ (await terminal.transcript()).clear();
12126
+ }
12127
+ await session.write(submit ? `${body}${ENTER}` : body);
12128
+ return { sent: body, submitted: submit };
12129
+ };
12130
+ var terminalAwaitExecutor = async (ctx) => {
12131
+ const config = configOf2(ctx.node);
12132
+ const terminal = requireTerminal(ctx);
12133
+ const session = await terminal.session();
12134
+ const transcript = await terminal.transcript();
12135
+ const raw = text2(config, "pattern").trim();
12136
+ if (!raw) fail(ctx, "terminal_await has no pattern configured \u2014 there is nothing for it to wait for");
12137
+ const pattern = compilePattern(ctx, raw, config);
12138
+ const timeoutMs = millis(config, "timeoutMs", DEFAULT_TIMEOUT_MS);
12139
+ const result = await transcript.waitFor(pattern, {
12140
+ timeoutMs,
12141
+ exited: session.exited
12142
+ });
12143
+ if (result.status === "exited") {
12144
+ fail(
12145
+ ctx,
12146
+ `The terminal exited (code ${result.exitCode}${result.signal ? `, signal ${result.signal}` : ""}) while waiting for ${JSON.stringify(raw)}. It never appeared, and the lane's session is gone.`
12147
+ );
12148
+ }
12149
+ if (result.status === "timeout") {
12150
+ if (config.onTimeout === "continue") {
12151
+ ctx.emit({
12152
+ type: "log",
12153
+ nodeId: ctx.node.id,
12154
+ level: "warn",
12155
+ message: `terminal_await: ${JSON.stringify(raw)} did not appear within ${timeoutMs}ms; continuing as configured.`
12156
+ });
12157
+ return { matched: false, output: result.text, groups: [] };
12158
+ }
12159
+ fail(
12160
+ ctx,
12161
+ `${JSON.stringify(raw)} did not appear within ${timeoutMs}ms. Last output:
12162
+ ${result.text.slice(-2e3)}`
12163
+ );
12164
+ }
12165
+ return {
12166
+ matched: true,
12167
+ output: result.text,
12168
+ matchedText: result.match[0],
12169
+ // Capture groups are the reason to use regex mode at all — a prompt that
12170
+ // reports a session id or a file path is only useful if the value comes out.
12171
+ groups: result.match.slice(1)
12172
+ };
12173
+ };
12174
+
11549
12175
  // src/registry/builtin.ts
11550
12176
  var HUMAN_FIELD_OUTPUT_TYPE = {
11551
12177
  text: "string",
@@ -11835,6 +12461,9 @@ var KINDS = [
11835
12461
  // returns its input on the chosen port
11836
12462
  emits: "input",
11837
12463
  aliases: ["branch", "@fancy/branch"],
12464
+ // Pure logic: no I/O to choose, so a default is safe. A host
12465
+ // executor still wins -- pickExecutor consults the registry first.
12466
+ executor: branchExecutor,
11838
12467
  category: "logic",
11839
12468
  label: "Branch",
11840
12469
  description: "Multi-way branch on a condition or value.",
@@ -11932,6 +12561,9 @@ var KINDS = [
11932
12561
  { path: "count", type: "number" }
11933
12562
  ],
11934
12563
  aliases: ["for_each", "@fancy/for_each"],
12564
+ // Pure logic: no I/O to choose, so a default is safe. A host
12565
+ // executor still wins -- pickExecutor consults the registry first.
12566
+ executor: forEachExecutor,
11935
12567
  category: "logic",
11936
12568
  label: "For Each",
11937
12569
  description: "Iterate over a list, emitting each item on `item`.",
@@ -11952,6 +12584,9 @@ var KINDS = [
11952
12584
  // reference.
11953
12585
  emits: (config) => (config?.mode ?? "merge") === "concat" ? null : "inputs-merged",
11954
12586
  aliases: ["merge", "@fancy/merge"],
12587
+ // Pure logic: no I/O to choose, so a default is safe. A host
12588
+ // executor still wins -- pickExecutor consults the registry first.
12589
+ executor: mergeExecutor,
11955
12590
  category: "logic",
11956
12591
  label: "Merge",
11957
12592
  description: "Combine multiple inputs into one object or array.",
@@ -11997,6 +12632,9 @@ var KINDS = [
11997
12632
  // else the shape that expression names. So the RELATION is config-dependent.
11998
12633
  emits: (config) => (config?.expression ?? "") === "" ? "input" : "expression:expression",
11999
12634
  aliases: ["transform", "@fancy/transform"],
12635
+ // Pure logic: no I/O to choose, so a default is safe. A host
12636
+ // executor still wins -- pickExecutor consults the registry first.
12637
+ executor: transformExecutor,
12000
12638
  category: "logic",
12001
12639
  label: "Transform",
12002
12640
  description: "Reshape data with an expression.",
@@ -12455,6 +13093,179 @@ var KINDS = [
12455
13093
  resizable: { minWidth: 160, minHeight: 72 },
12456
13094
  component: LaneNode
12457
13095
  },
13096
+ {
13097
+ // Terminal lane — a lane that OWNS a terminal for the length of the run.
13098
+ //
13099
+ // Visually a swimlane, and `layout` for the same reason the plain lane is:
13100
+ // a lane is not a step, so it never executes and never appears in topo
13101
+ // order. What is new is that it DECLARES a resource. The runtime opens its
13102
+ // terminal lazily, when the first terminal node inside it runs, and closes
13103
+ // it in the run's `finally` — so a graph that never reaches a terminal node
13104
+ // never spawns a process, and one that does gets exactly one session no
13105
+ // matter how many nodes use it.
13106
+ //
13107
+ // Membership is `parentId`, which already persists into the WorkflowSchema.
13108
+ // That is what lets a headless runtime resolve the same grouping the canvas
13109
+ // shows, without a second association to keep in sync.
13110
+ name: "@particle-academy/terminal_lane",
13111
+ aliases: ["terminal_lane", "@fancy/terminal_lane"],
13112
+ category: "layout",
13113
+ label: "Terminal lane",
13114
+ description: "A lane that owns one terminal. Opens at the first terminal node, closes when the run ends.",
13115
+ icon: "\u25A3",
13116
+ inputs: [],
13117
+ outputs: [],
13118
+ configSchema: [
13119
+ { type: "text", key: "title", label: "Title", default: "Terminal" },
13120
+ {
13121
+ type: "text",
13122
+ key: "command",
13123
+ label: "Command",
13124
+ placeholder: "Leave empty for the default shell"
13125
+ },
13126
+ { type: "text", key: "cwd", label: "Working directory", placeholder: "Host default" },
13127
+ { type: "keyvalue", key: "env", label: "Environment" },
13128
+ {
13129
+ type: "select",
13130
+ key: "orientation",
13131
+ label: "Orientation",
13132
+ default: "horizontal",
13133
+ options: [
13134
+ { value: "horizontal", label: "Row" },
13135
+ { value: "vertical", label: "Column" }
13136
+ ]
13137
+ }
13138
+ ],
13139
+ defaultConfig: { title: "Terminal", orientation: "horizontal" },
13140
+ resizable: { minWidth: 220, minHeight: 120 },
13141
+ component: LaneNode
13142
+ },
13143
+ {
13144
+ // Terminal nodes. All three live inside a terminal lane and talk to the
13145
+ // session that lane owns; outside one they abort by name rather than
13146
+ // opening a shell of their own.
13147
+ name: "@particle-academy/terminal_run",
13148
+ aliases: ["terminal_run", "@fancy/terminal_run"],
13149
+ category: "io",
13150
+ label: "Run in terminal",
13151
+ description: "Run a shell command in the lane's terminal and wait for its exit code.",
13152
+ icon: "$",
13153
+ inputs: [{ id: "in" }],
13154
+ outputs: [{ id: "out", label: "output" }],
13155
+ executor: terminalRunExecutor,
13156
+ configSchema: [
13157
+ {
13158
+ type: "textarea",
13159
+ key: "command",
13160
+ label: "Command",
13161
+ rows: 3,
13162
+ required: true,
13163
+ placeholder: "npm test",
13164
+ description: "Runs in the lane's shell, so `cd` and exported variables persist between nodes. SHELL ONLY \u2014 a TUI never returns to a prompt, so use Send + Await for one."
13165
+ },
13166
+ {
13167
+ type: "number",
13168
+ key: "timeoutMs",
13169
+ label: "Timeout (ms)",
13170
+ default: 12e4,
13171
+ description: "How long to wait for the command to finish before failing the run."
13172
+ },
13173
+ {
13174
+ type: "switch",
13175
+ key: "failOnNonZero",
13176
+ label: "Fail the run on a non-zero exit",
13177
+ default: true,
13178
+ description: "On by default. Turning it off means a failed command lets the run report success \u2014 read the exit code on the output port and branch on it instead."
13179
+ }
13180
+ ],
13181
+ defaultConfig: { command: "", timeoutMs: 12e4, failOnNonZero: true }
13182
+ },
13183
+ {
13184
+ name: "@particle-academy/terminal_send",
13185
+ aliases: ["terminal_send", "@fancy/terminal_send"],
13186
+ category: "io",
13187
+ label: "Send to terminal",
13188
+ description: "Type text at whatever is running in the lane's terminal, without waiting.",
13189
+ icon: "\u2328",
13190
+ inputs: [{ id: "in" }],
13191
+ outputs: [{ id: "out", label: "sent" }],
13192
+ executor: terminalSendExecutor,
13193
+ configSchema: [
13194
+ {
13195
+ type: "textarea",
13196
+ key: "text",
13197
+ label: "Text",
13198
+ rows: 4,
13199
+ placeholder: "Summarise the failing test and propose a fix.",
13200
+ description: "What to type. This is how a graph prompts an agent TUI such as Claude Code or Codex."
13201
+ },
13202
+ {
13203
+ type: "switch",
13204
+ key: "submit",
13205
+ label: "Press Enter",
13206
+ default: true,
13207
+ description: "Off leaves the text on the input line \u2014 useful for building one up across several nodes."
13208
+ },
13209
+ {
13210
+ type: "switch",
13211
+ key: "clearFirst",
13212
+ label: "Forget earlier output first",
13213
+ default: false,
13214
+ description: "Discards anything the terminal said before this send, so a following Await cannot match a prompt left over from the previous exchange."
13215
+ }
13216
+ ],
13217
+ defaultConfig: { text: "", submit: true, clearFirst: false }
13218
+ },
13219
+ {
13220
+ name: "@particle-academy/terminal_await",
13221
+ aliases: ["terminal_await", "@fancy/terminal_await"],
13222
+ category: "io",
13223
+ label: "Await terminal output",
13224
+ description: "Wait until the lane's terminal prints something that matches.",
13225
+ icon: "\u23F1",
13226
+ inputs: [{ id: "in" }],
13227
+ outputs: [{ id: "out", label: "output" }],
13228
+ executor: terminalAwaitExecutor,
13229
+ configSchema: [
13230
+ {
13231
+ type: "text",
13232
+ key: "pattern",
13233
+ label: "Wait for",
13234
+ required: true,
13235
+ placeholder: "esc to interrupt",
13236
+ description: "Matched against the output with colour codes already stripped, so match what you SEE."
13237
+ },
13238
+ {
13239
+ type: "select",
13240
+ key: "mode",
13241
+ label: "Match as",
13242
+ default: "text",
13243
+ options: [
13244
+ { value: "text", label: "Plain text" },
13245
+ { value: "regex", label: "Regular expression" }
13246
+ ],
13247
+ description: "Regex mode also returns capture groups, which is how a value gets out of a prompt."
13248
+ },
13249
+ {
13250
+ type: "number",
13251
+ key: "timeoutMs",
13252
+ label: "Timeout (ms)",
13253
+ default: 12e4
13254
+ },
13255
+ {
13256
+ type: "select",
13257
+ key: "onTimeout",
13258
+ label: "If it never appears",
13259
+ default: "fail",
13260
+ options: [
13261
+ { value: "fail", label: "Fail the run" },
13262
+ { value: "continue", label: "Continue with matched: false" }
13263
+ ],
13264
+ description: "Failing is the default. Continuing lets the next node type at a process that never became ready while the run still reports success, so it has to be asked for."
13265
+ }
13266
+ ],
13267
+ defaultConfig: { pattern: "", mode: "text", timeoutMs: 12e4, onTimeout: "fail" }
13268
+ },
12458
13269
  {
12459
13270
  // Note — a sticky-note annotation. Portless + visual-only: the runtime skips
12460
13271
  // the `annotation` category, so a note's text NEVER reaches a runner — it
@@ -13214,7 +14025,7 @@ var CATEGORY_LABELS = {
13214
14025
  annotation: "Notes",
13215
14026
  custom: "Custom"
13216
14027
  };
13217
- function NodePalette({ categories, onPick, className, style: style2 }) {
14028
+ function NodePalette({ categories, kindFilter, onPick, className, style: style2 }) {
13218
14029
  const [, setRev] = ReactExports.useState(0);
13219
14030
  ReactExports.useEffect(() => onNodeKindsChanged(() => setRev((n) => n + 1)), []);
13220
14031
  const [query, setQuery] = ReactExports.useState("");
@@ -13222,7 +14033,8 @@ function NodePalette({ categories, onPick, className, style: style2 }) {
13222
14033
  const grouped = ReactExports.useMemo(() => {
13223
14034
  const all = listNodeKinds();
13224
14035
  const q2 = query.trim().toLowerCase();
13225
- const filtered = q2 ? all.filter((k2) => k2.name.includes(q2) || k2.label.toLowerCase().includes(q2) || (k2.description ?? "").toLowerCase().includes(q2)) : all;
14036
+ const allowed = kindFilter ? all.filter((k2) => kindFilter({ kind: k2 })) : all;
14037
+ const filtered = q2 ? allowed.filter((k2) => k2.name.includes(q2) || k2.label.toLowerCase().includes(q2) || (k2.description ?? "").toLowerCase().includes(q2)) : allowed;
13226
14038
  const map = /* @__PURE__ */ new Map();
13227
14039
  for (const cat of visibleCats) map.set(cat, []);
13228
14040
  for (const k2 of filtered) {
@@ -13230,7 +14042,7 @@ function NodePalette({ categories, onPick, className, style: style2 }) {
13230
14042
  map.get(k2.category).push(k2);
13231
14043
  }
13232
14044
  return map;
13233
- }, [query, visibleCats]);
14045
+ }, [query, visibleCats, kindFilter]);
13234
14046
  return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: ["ff-palette", className ?? ""].filter(Boolean).join(" "), style: style2, children: [
13235
14047
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "ff-palette__search", children: /* @__PURE__ */ jsxRuntime.jsx(
13236
14048
  "input",
@@ -13355,8 +14167,8 @@ function describeExpressionGrammar() {
13355
14167
  note: "Dot-paths only \u2014 no arithmetic, comparisons or function calls; anything else resolves to nothing. A field that is exactly one expression keeps the resolved value's type; mixed text interpolates as a string. fancy-flow's own runFlow does not interpolate: it hands config to your executor verbatim, and the fancy-flow-php runtime is what resolves these."
13356
14168
  };
13357
14169
  }
13358
- function findTrigger(text2, caret) {
13359
- const before = text2.slice(0, caret);
14170
+ function findTrigger(text3, caret) {
14171
+ const before = text3.slice(0, caret);
13360
14172
  const open = before.lastIndexOf("{{");
13361
14173
  if (open === -1) return null;
13362
14174
  if (before.indexOf("}}", open) !== -1) return null;
@@ -13367,9 +14179,9 @@ function filterVariables(vars, query) {
13367
14179
  if (q2 === "") return vars;
13368
14180
  return vars.filter((v2) => v2.path.toLowerCase().includes(q2));
13369
14181
  }
13370
- function applyCompletion(text2, caret, trigger, variable) {
13371
- const head = text2.slice(0, trigger.open);
13372
- const tail = text2.slice(caret);
14182
+ function applyCompletion(text3, caret, trigger, variable) {
14183
+ const head = text3.slice(0, trigger.open);
14184
+ const tail = text3.slice(caret);
13373
14185
  return { value: `${head}${variable.expression}${tail}`, caret: head.length + variable.expression.length };
13374
14186
  }
13375
14187
  function ExpressionField({
@@ -13893,7 +14705,7 @@ function JsonField({
13893
14705
  return "";
13894
14706
  }
13895
14707
  }, [value]);
13896
- const [text2, setText] = ReactExports.useState(serialized);
14708
+ const [text3, setText] = ReactExports.useState(serialized);
13897
14709
  const [error, setError] = ReactExports.useState(null);
13898
14710
  const [touched, setTouched] = ReactExports.useState(false);
13899
14711
  const lastProps = ReactExports.useRef(serialized);
@@ -13931,7 +14743,7 @@ function JsonField({
13931
14743
  ...handle,
13932
14744
  className: "ff-panel__input ff-panel__input--json",
13933
14745
  rows: rows ?? 6,
13934
- value: text2,
14746
+ value: text3,
13935
14747
  spellCheck: false,
13936
14748
  "aria-invalid": showError || void 0,
13937
14749
  "aria-describedby": showError ? errorId : void 0,
@@ -14996,6 +15808,7 @@ function FlowEditorInner({
14996
15808
  showPanel = true,
14997
15809
  fieldRenderers,
14998
15810
  fieldFilter,
15811
+ kindFilter,
14999
15812
  showFeed = true,
15000
15813
  extraToolbar,
15001
15814
  actions = [],
@@ -15016,7 +15829,7 @@ function FlowEditorInner({
15016
15829
  const [prompt, setPrompt] = ReactExports.useState(null);
15017
15830
  const humanExecutors = ReactExports.useMemo(
15018
15831
  () => ({
15019
- user_input: ({ node }) => new Promise((resolve) => {
15832
+ user_input: ({ node }) => new Promise((resolve2) => {
15020
15833
  const cfg = node.data?.config ?? {};
15021
15834
  setPrompt({
15022
15835
  kind: "input",
@@ -15025,11 +15838,11 @@ function FlowEditorInner({
15025
15838
  fields: humanInputFields(cfg),
15026
15839
  resolve: (values) => {
15027
15840
  setPrompt(null);
15028
- resolve(values);
15841
+ resolve2(values);
15029
15842
  }
15030
15843
  });
15031
15844
  }),
15032
- human_approval: ({ node }) => new Promise((resolve) => {
15845
+ human_approval: ({ node }) => new Promise((resolve2) => {
15033
15846
  const cfg = node.data?.config ?? {};
15034
15847
  setPrompt({
15035
15848
  kind: "approval",
@@ -15037,7 +15850,7 @@ function FlowEditorInner({
15037
15850
  description: typeof cfg.description === "string" ? cfg.description : void 0,
15038
15851
  resolve: (approved) => {
15039
15852
  setPrompt(null);
15040
- resolve({ branch: approved ? "approved" : "denied" });
15853
+ resolve2({ branch: approved ? "approved" : "denied" });
15041
15854
  }
15042
15855
  });
15043
15856
  })
@@ -15423,7 +16236,7 @@ function FlowEditorInner({
15423
16236
  ] })
15424
16237
  ] });
15425
16238
  return /* @__PURE__ */ jsxRuntime.jsxs(FlowEditorProvider, { value: api, children: [
15426
- showPalette && (slots.palette ? slots.palette(api) : /* @__PURE__ */ jsxRuntime.jsx(NodePalette, { className: "ff-editor__palette" })),
16239
+ showPalette && (slots.palette ? slots.palette(api) : /* @__PURE__ */ jsxRuntime.jsx(NodePalette, { className: "ff-editor__palette", kindFilter })),
15427
16240
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "ff-editor__main", ...dropHandlers, children: [
15428
16241
  /* @__PURE__ */ jsxRuntime.jsx(
15429
16242
  FlowCanvas,
@@ -15536,8 +16349,8 @@ function FlowEditorInner({
15536
16349
  x: labelEdit.x,
15537
16350
  y: labelEdit.y,
15538
16351
  initial: flow.edges.find((e) => e.id === labelEdit.edgeId)?.label ?? "",
15539
- onCommit: (text2) => {
15540
- api.setEdgeLabel(labelEdit.edgeId, text2);
16352
+ onCommit: (text3) => {
16353
+ api.setEdgeLabel(labelEdit.edgeId, text3);
15541
16354
  setLabelEdit(null);
15542
16355
  },
15543
16356
  onCancel: () => setLabelEdit(null)
@@ -15650,7 +16463,7 @@ function EdgeLabelEditor({
15650
16463
  onCommit,
15651
16464
  onCancel
15652
16465
  }) {
15653
- const [text2, setText] = ReactExports.useState(initial);
16466
+ const [text3, setText] = ReactExports.useState(initial);
15654
16467
  const ref = ReactExports.useRef(null);
15655
16468
  ReactExports.useEffect(() => {
15656
16469
  ref.current?.focus();
@@ -15667,17 +16480,17 @@ function EdgeLabelEditor({
15667
16480
  {
15668
16481
  ref,
15669
16482
  className: "ff-panel__input",
15670
- value: text2,
16483
+ value: text3,
15671
16484
  placeholder: "Label this connection",
15672
16485
  "aria-label": "Connection label",
15673
16486
  "data-action": "edge-label-input",
15674
16487
  onChange: (e) => setText(e.target.value),
15675
16488
  onKeyDown: (e) => {
15676
16489
  e.stopPropagation();
15677
- if (e.key === "Enter") onCommit(text2);
16490
+ if (e.key === "Enter") onCommit(text3);
15678
16491
  if (e.key === "Escape") onCancel();
15679
16492
  },
15680
- onBlur: () => onCommit(text2)
16493
+ onBlur: () => onCommit(text3)
15681
16494
  }
15682
16495
  )
15683
16496
  }
@@ -15914,106 +16727,6 @@ var flowKeys = {
15914
16727
  run: (runId) => ["flow", "runs", runId]
15915
16728
  };
15916
16729
 
15917
- // src/expressions/expr.ts
15918
- function wholeExpression(trimmed) {
15919
- if (trimmed.length < 4) return null;
15920
- if (!trimmed.startsWith("{{") || !trimmed.endsWith("}}")) return null;
15921
- return trimmed.slice(2, -2);
15922
- }
15923
- function interpolate(template, resolve) {
15924
- let out = "";
15925
- let i = 0;
15926
- for (; ; ) {
15927
- const open = template.indexOf("{{", i);
15928
- if (open === -1) return out + template.slice(i);
15929
- const close = template.indexOf("}}", open + 2);
15930
- if (close === -1) return out + template.slice(i);
15931
- out += template.slice(i, open) + resolve(template.slice(open + 2, close));
15932
- i = close + 2;
15933
- }
15934
- }
15935
- var FALSY_STRINGS = /* @__PURE__ */ new Set(["", "0", "false", "no", "off", "null"]);
15936
- var UnresolvedPathError = class extends Error {
15937
- constructor(path) {
15938
- super(
15939
- `Expression path "${path}" did not resolve. Under the "throw" policy an unresolvable path is an error rather than an empty string.`
15940
- );
15941
- this.path = path;
15942
- this.name = "UnresolvedPathError";
15943
- }
15944
- };
15945
- function tryResolvePath(path, context) {
15946
- const unresolved = { resolved: false, value: null };
15947
- const trimmed = path.trim();
15948
- if (trimmed === "") return unresolved;
15949
- const segments = trimmed.split(".");
15950
- let cursor;
15951
- const head = segments[0];
15952
- if (head === "$json" || head === "$input") {
15953
- cursor = context !== null && typeof context === "object" && "in" in context ? context.in : context;
15954
- segments.shift();
15955
- } else {
15956
- cursor = context;
15957
- }
15958
- for (const segment of segments) {
15959
- if (cursor === null || cursor === void 0) return unresolved;
15960
- if (typeof cursor !== "object") return unresolved;
15961
- const next = cursor[segment];
15962
- if (next === void 0) return unresolved;
15963
- cursor = next;
15964
- }
15965
- return { resolved: true, value: cursor === void 0 ? null : cursor };
15966
- }
15967
- function resolvePath(path, context) {
15968
- return tryResolvePath(path, context).value;
15969
- }
15970
- function evaluateExpression(template, context, options = {}) {
15971
- if (typeof template !== "string") return template;
15972
- const policy = options.onUnresolved ?? "empty";
15973
- const whole = wholeExpression(template.trim());
15974
- if (whole !== null) {
15975
- const r = tryResolvePath(whole, context);
15976
- if (r.resolved) return r.value;
15977
- if (policy === "throw") throw new UnresolvedPathError(whole);
15978
- return policy === "keep" ? template : null;
15979
- }
15980
- return interpolate(template, (path) => {
15981
- const r = tryResolvePath(path, context);
15982
- if (r.resolved) return stringify(r.value);
15983
- if (policy === "throw") throw new UnresolvedPathError(path);
15984
- return policy === "keep" ? `{{${path}}}` : "";
15985
- });
15986
- }
15987
- function truthy(value) {
15988
- if (typeof value === "boolean") return value;
15989
- if (value === null || value === void 0) return false;
15990
- if (typeof value === "string") return !FALSY_STRINGS.has(value.trim().toLowerCase());
15991
- if (Array.isArray(value)) return value.length > 0;
15992
- if (typeof value === "number") return value !== 0;
15993
- return Boolean(value);
15994
- }
15995
- function text(value) {
15996
- return stringify(value);
15997
- }
15998
- function stringify(value) {
15999
- if (typeof value === "string") return value;
16000
- if (typeof value === "boolean") return value ? "true" : "false";
16001
- if (value === null || value === void 0) return "";
16002
- if (typeof value === "number" || typeof value === "bigint") return String(value);
16003
- try {
16004
- return JSON.stringify(value) ?? "";
16005
- } catch {
16006
- return "";
16007
- }
16008
- }
16009
- function evaluateConfig(config, context, options = {}) {
16010
- const out = {};
16011
- for (const [key, value] of Object.entries(config)) {
16012
- out[key] = Array.isArray(value) ? value.map((v2) => evaluateExpression(v2, context, options)) : value !== null && typeof value === "object" ? evaluateConfig(value, context, options) : evaluateExpression(value, context, options);
16013
- }
16014
- return out;
16015
- }
16016
-
16017
16730
  // src/index.ts
16018
16731
  ensureBuiltinKinds();
16019
16732
  /*! Bundled license information: