runline 0.15.0 → 0.16.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.
@@ -37,5 +37,13 @@ export declare class ExecutionEngine {
37
37
  constructor(registry: PluginRegistry, config: RunlineConfig);
38
38
  execute(code: string, options?: EngineOptions): Promise<ExecuteResult>;
39
39
  private invokeAction;
40
+ /**
41
+ * A miss on "salesforce.status" is often a plugin that registered
42
+ * its action name with the plugin prefix ("salesforce.status" inside
43
+ * plugin "salesforce"), making the real path
44
+ * "salesforce.salesforce.status". Detect exactly that shape and
45
+ * teach the caller — agents read error messages and self-correct.
46
+ */
47
+ private selfPrefixHint;
40
48
  private resolveConnection;
41
49
  }
@@ -158,7 +158,7 @@ export class ExecutionEngine {
158
158
  async invokeAction(path, args) {
159
159
  const resolved = this.registry.resolveAction(path);
160
160
  if (!resolved) {
161
- throw new Error(`Unknown action: ${path}`);
161
+ throw new Error(`Unknown action: ${path}${this.selfPrefixHint(path)}`);
162
162
  }
163
163
  const { plugin, action } = resolved;
164
164
  const connection = this.resolveConnection(plugin);
@@ -184,6 +184,25 @@ export class ExecutionEngine {
184
184
  }
185
185
  return action.execute(args, ctx);
186
186
  }
187
+ /**
188
+ * A miss on "salesforce.status" is often a plugin that registered
189
+ * its action name with the plugin prefix ("salesforce.status" inside
190
+ * plugin "salesforce"), making the real path
191
+ * "salesforce.salesforce.status". Detect exactly that shape and
192
+ * teach the caller — agents read error messages and self-correct.
193
+ */
194
+ selfPrefixHint(path) {
195
+ // A dot-less miss can still be the exact-name case: plugin "ping"
196
+ // registering action "ping" makes the callable path "ping.ping".
197
+ const dot = path.indexOf(".");
198
+ const pluginName = dot < 0 ? path : path.slice(0, dot);
199
+ const doubled = `${pluginName}.${path}`;
200
+ if (!this.registry.resolveAction(doubled))
201
+ return "";
202
+ return (`. Did you mean "${doubled}"? Plugin "${pluginName}" registered its ` +
203
+ `action name with the plugin prefix ("${path}"), so the full callable ` +
204
+ `path is "${doubled}".`);
205
+ }
187
206
  resolveConnection(plugin) {
188
207
  const conn = this.config.connections.find((c) => c.plugin === plugin.name);
189
208
  const base = conn ?? {
@@ -59,14 +59,39 @@ export function createPluginAPI(pluginId) {
59
59
  export function isPluginFunction(val) {
60
60
  return typeof val === "function";
61
61
  }
62
+ /**
63
+ * Action names are plugin-relative: plugin "salesforce" registering
64
+ * "salesforce.status" is callable as "salesforce.salesforce.status",
65
+ * which is almost never what the author meant. Warn at resolve time —
66
+ * the earliest point where both the final plugin name and the full
67
+ * action list are known (setName may be called after registerAction,
68
+ * and object exports never go through createPluginAPI).
69
+ */
70
+ function warnSelfPrefixedActions(plugin) {
71
+ for (const action of plugin.actions) {
72
+ const prefixed = action.name.startsWith(`${plugin.name}.`);
73
+ if (!prefixed && action.name !== plugin.name)
74
+ continue;
75
+ const advice = prefixed
76
+ ? `Rename the action to "${action.name.slice(plugin.name.length + 1)}".`
77
+ : "Choose an action name that does not repeat the plugin name.";
78
+ console.warn(`[runline] Plugin "${plugin.name}" registered action "${action.name}", which repeats the plugin name. ` +
79
+ `Action names are plugin-relative, so this action is callable as "${plugin.name}.${action.name}" — ` +
80
+ `not "${action.name}". ${advice}`);
81
+ }
82
+ }
62
83
  export function resolvePluginExport(exported, pluginId) {
63
84
  if (isPluginFunction(exported)) {
64
85
  const { api, resolve } = createPluginAPI(pluginId);
65
86
  exported(api);
66
- return resolve();
87
+ const plugin = resolve();
88
+ warnSelfPrefixedActions(plugin);
89
+ return plugin;
67
90
  }
68
91
  if (exported && typeof exported === "object" && "actions" in exported) {
69
- return exported;
92
+ const plugin = exported;
93
+ warnSelfPrefixedActions(plugin);
94
+ return plugin;
70
95
  }
71
96
  throw new Error(`Invalid plugin export from "${pluginId}": expected a function or { name, actions } object`);
72
97
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Render agent-authored React Email JSX into wire-ready email HTML
3
+ * plus a plain-text alternative.
4
+ *
5
+ * Why JSX instead of hand-written HTML: email-grade HTML (table
6
+ * layouts, inlined styles, client quirks) is exactly what agents get
7
+ * wrong; @react-email/components encodes that knowledge, and agents
8
+ * write JSX fluently. Evaluating agent-supplied JSX adds no new risk
9
+ * class — runline's executor is already a coding surface.
10
+ *
11
+ * All heavy dependencies (react, sucrase, react-email) are optional
12
+ * peers of runline — like playwright for browser actions — loaded
13
+ * lazily on first use. Hosts that never render JSX pay nothing;
14
+ * hosts without the packages get an install hint instead of a
15
+ * module-resolution stack trace.
16
+ */
17
+ const PEER_INSTALL_HINT = "jsx email bodies require optional peer packages. Install them in the host project: " +
18
+ "react react-dom @react-email/components @react-email/render sucrase. " +
19
+ "Alternatively pass `html` or `text` instead of `jsx`.";
20
+ let modulesPromise;
21
+ async function loadModules() {
22
+ modulesPromise ??= Promise.all([
23
+ import("react"),
24
+ import("@react-email/components"),
25
+ import("@react-email/render"),
26
+ import("sucrase"),
27
+ ]).then(([react, components, renderMod, sucrase]) => ({
28
+ React: react.default ?? react,
29
+ components: components,
30
+ render: renderMod.render,
31
+ transform: sucrase.transform,
32
+ }), (err) => {
33
+ // Don't cache the failure — the host can install the peers and
34
+ // retry without restarting the process.
35
+ modulesPromise = undefined;
36
+ const msg = err instanceof Error ? err.message : String(err);
37
+ throw new Error(`${PEER_INSTALL_HINT} (${msg})`);
38
+ });
39
+ return modulesPromise;
40
+ }
41
+ /**
42
+ * Components become named parameters of the evaluation function, so
43
+ * only exports that are valid JS identifiers (the React components —
44
+ * capitalized) can be exposed. Lowercase utility exports are omitted
45
+ * on purpose: the JSX surface is components-only.
46
+ */
47
+ function componentScope(components) {
48
+ const names = [];
49
+ const values = [];
50
+ for (const [key, value] of Object.entries(components)) {
51
+ if (!/^[A-Z][A-Za-z0-9_$]*$/.test(key))
52
+ continue;
53
+ names.push(key);
54
+ values.push(value);
55
+ }
56
+ return { names, values };
57
+ }
58
+ export async function renderEmailJsx(jsx) {
59
+ const source = jsx.trim();
60
+ if (!source)
61
+ throw new Error("jsx body is empty");
62
+ const { React, components, render, transform } = await loadModules();
63
+ let compiled;
64
+ try {
65
+ // Wrap as an expression statement so a bare `<Html>…</Html>` is a
66
+ // valid program for sucrase. The parentheses nudge the surface
67
+ // toward a single expression (statement forms like `const x = 1`
68
+ // fail to parse) — a shape hint, not a boundary: the executor is
69
+ // already a coding surface, so escaping it grants nothing.
70
+ compiled = transform(`__element = (${source});`, {
71
+ transforms: ["jsx"],
72
+ production: true,
73
+ }).code;
74
+ }
75
+ catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err);
77
+ throw new Error(`jsx parse failed: ${msg}`);
78
+ }
79
+ const { names, values } = componentScope(components);
80
+ let element;
81
+ try {
82
+ const fn = new Function("React", ...names, `let __element; ${compiled}; return __element;`);
83
+ element = fn(React, ...values);
84
+ }
85
+ catch (err) {
86
+ const msg = err instanceof Error ? err.message : String(err);
87
+ throw new Error(`jsx evaluation failed: ${msg}. Available components: ${names.join(", ")}`);
88
+ }
89
+ if (!React.isValidElement(element)) {
90
+ throw new Error("jsx must evaluate to a single React element");
91
+ }
92
+ const [html, text] = await Promise.all([
93
+ render(element),
94
+ render(element, { plainText: true }),
95
+ ]);
96
+ return { html, text };
97
+ }
@@ -21,6 +21,7 @@
21
21
  * or parsed `payload` tree for format=full).
22
22
  */
23
23
  import * as t from "typebox";
24
+ import { renderEmailJsx } from "../../_shared/emailJsx.js";
24
25
  import { googleAccessToken } from "../../_shared/googleAuth.js";
25
26
  import { GoogleTimestamp, Id, NonEmptyString, stringEnum, } from "../../_shared/googleSchemas.js";
26
27
  // ─── Auth ────────────────────────────────────────────────────────
@@ -282,6 +283,32 @@ function normalizeAddressList(input) {
282
283
  .map((p) => (p.includes("<") && p.includes(">") ? p : `<${p}>`))
283
284
  .join(", ");
284
285
  }
286
+ // ─── Body resolution ─────────────────────────────────────────────
287
+ /**
288
+ * Resolve the outgoing body for send/reply/draft: `jsx` renders to
289
+ * html with a generated plain-text alternative; an explicit `text`
290
+ * wins over the generated fallback so every jsx email still ships
291
+ * multipart/alternative.
292
+ * Composing inputs carry no `html` field — jsx is the only HTML path
293
+ * (raw HTML embeds go through dangerouslySetInnerHTML inside jsx).
294
+ */
295
+ async function resolveBody(p) {
296
+ let text = p.text;
297
+ let html;
298
+ if (typeof p.jsx === "string" && p.jsx.trim()) {
299
+ let rendered;
300
+ try {
301
+ rendered = await renderEmailJsx(p.jsx);
302
+ }
303
+ catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
305
+ throw new Error(`gmail: ${msg}`);
306
+ }
307
+ html = rendered.html;
308
+ text ??= rendered.text;
309
+ }
310
+ return { text, html };
311
+ }
285
312
  function findHeader(payload, name) {
286
313
  if (!payload?.headers)
287
314
  return undefined;
@@ -444,6 +471,9 @@ async function replyToMessage(ctx, messageId, p) {
444
471
  if (p.replyToSenderOnly && p.replyToRecipientsOnly) {
445
472
  throw new Error("gmail: replyToSenderOnly and replyToRecipientsOnly are mutually exclusive");
446
473
  }
474
+ // Render before any network round-trip so malformed jsx fails
475
+ // without spending Gmail API calls.
476
+ const { text, html } = await resolveBody(p);
447
477
  const original = (await gmailRequest(ctx, "GET", `/messages/${messageId}`, undefined, { format: "metadata", metadataHeaders: REPLY_METADATA_HEADERS }));
448
478
  const subject = findHeader(original.payload, "Subject") ?? "";
449
479
  const messageIdHeader = findHeader(original.payload, "Message-ID") ?? "";
@@ -487,8 +517,8 @@ async function replyToMessage(ctx, messageId, p) {
487
517
  subject: subject.toLowerCase().startsWith("re:")
488
518
  ? subject
489
519
  : `Re: ${subject}`,
490
- text: p.text,
491
- html: p.html,
520
+ text,
521
+ html,
492
522
  inReplyTo: messageIdHeader,
493
523
  references: messageIdHeader,
494
524
  attachments: p.attachments,
@@ -532,6 +562,9 @@ const Attachment = t.Object({
532
562
  contentBase64: AttachmentContent,
533
563
  }, strict);
534
564
  const Attachments = t.Array(Attachment);
565
+ const Jsx = t.Optional(t.String({
566
+ description: 'React Email JSX expression rendered server-side into client-safe HTML with an auto-generated plain-text alternative — handles cross-client rendering (iOS Mail, Gmail, Outlook). For Hebrew/Arabic set dir on the root: `<Html dir="rtl" lang="he"><Container><Heading>שלום</Heading><Text>גוף ההודעה</Text><Button href="...">אישור</Button></Container></Html>`. To embed pre-rendered HTML from elsewhere: `<Html><Body><div dangerouslySetInnerHTML={{ __html: theHtml }} /></Body></Html>`.',
567
+ }));
535
568
  const HasMessageContent = {
536
569
  anyOf: [
537
570
  {
@@ -539,8 +572,8 @@ const HasMessageContent = {
539
572
  properties: { text: { type: "string", minLength: 1, pattern: "\\S" } },
540
573
  },
541
574
  {
542
- required: ["html"],
543
- properties: { html: { type: "string", minLength: 1, pattern: "\\S" } },
575
+ required: ["jsx"],
576
+ properties: { jsx: { type: "string", minLength: 1, pattern: "\\S" } },
544
577
  },
545
578
  {
546
579
  required: ["attachments"],
@@ -550,7 +583,7 @@ const HasMessageContent = {
550
583
  };
551
584
  const ReplyFields = {
552
585
  text: t.Optional(t.String()),
553
- html: t.Optional(t.String()),
586
+ jsx: Jsx,
554
587
  cc: t.Optional(t.String()),
555
588
  bcc: t.Optional(t.String()),
556
589
  replyToSenderOnly: t.Optional(t.Boolean()),
@@ -695,12 +728,12 @@ export default function gmail(rl) {
695
728
  // ── Message ───────────────────────────────────────────
696
729
  rl.registerAction("message.send", {
697
730
  access: "write",
698
- description: "Send an email",
731
+ description: "Send an email. `text` for plain bodies; `jsx` (React Email) for formatted or RTL (Hebrew/Arabic) bodies — renders client-safe HTML with a text fallback.",
699
732
  inputSchema: t.Object({
700
733
  to: NonEmptyString,
701
734
  subject: t.String(),
702
735
  text: t.Optional(t.String({ description: "Plain body" })),
703
- html: t.Optional(t.String({ description: "HTML body" })),
736
+ jsx: Jsx,
704
737
  cc: t.Optional(t.String()),
705
738
  bcc: t.Optional(t.String()),
706
739
  replyTo: t.Optional(t.String()),
@@ -710,6 +743,7 @@ export default function gmail(rl) {
710
743
  }, { ...strict, ...HasMessageContent }),
711
744
  async execute(input, ctx) {
712
745
  const p = (input ?? {});
746
+ const { text, html } = await resolveBody(p);
713
747
  const email = {
714
748
  to: normalizeAddressList(p.to),
715
749
  cc: normalizeAddressList(p.cc),
@@ -717,8 +751,8 @@ export default function gmail(rl) {
717
751
  replyTo: normalizeAddressList(p.replyTo),
718
752
  from: p.from,
719
753
  subject: p.subject ?? "",
720
- text: p.text,
721
- html: p.html,
754
+ text,
755
+ html,
722
756
  attachments: p.attachments,
723
757
  };
724
758
  const body = { raw: encodeEmail(email) };
@@ -729,7 +763,7 @@ export default function gmail(rl) {
729
763
  });
730
764
  rl.registerAction("message.reply", {
731
765
  access: "write",
732
- description: "Reply to a message, preserving threadId and In-Reply-To/References headers",
766
+ description: "Reply to a message, preserving threadId and In-Reply-To/References headers. `text` for plain bodies; `jsx` (React Email) for formatted ones.",
733
767
  inputSchema: t.Object({ messageId: Id, ...ReplyFields }, ReplyOptions),
734
768
  async execute(input, ctx) {
735
769
  const p = (input ?? {});
@@ -943,7 +977,7 @@ export default function gmail(rl) {
943
977
  });
944
978
  rl.registerAction("thread.reply", {
945
979
  access: "write",
946
- description: "Reply to the last message in a thread (convenience wrapper over message.reply)",
980
+ description: "Reply to the last message in a thread (convenience wrapper over message.reply). `text` for plain bodies; `jsx` (React Email) for formatted ones.",
947
981
  inputSchema: t.Object({ id: Id, ...ReplyFields }, ReplyOptions),
948
982
  async execute(input, ctx) {
949
983
  const p = (input ?? {});
@@ -958,12 +992,12 @@ export default function gmail(rl) {
958
992
  // ── Draft ─────────────────────────────────────────────
959
993
  rl.registerAction("draft.create", {
960
994
  access: "write",
961
- description: "Create a draft",
995
+ description: "Create a draft. `text` for plain bodies; `jsx` (React Email) for formatted ones.",
962
996
  inputSchema: t.Object({
963
997
  to: t.Optional(t.String()),
964
998
  subject: t.Optional(t.String()),
965
999
  text: t.Optional(t.String()),
966
- html: t.Optional(t.String()),
1000
+ jsx: Jsx,
967
1001
  cc: t.Optional(t.String()),
968
1002
  bcc: t.Optional(t.String()),
969
1003
  replyTo: t.Optional(t.String()),
@@ -977,6 +1011,7 @@ export default function gmail(rl) {
977
1011
  async execute(input, ctx) {
978
1012
  const p = (input ?? {});
979
1013
  const from = p.from ?? p.fromAlias;
1014
+ const { text, html } = await resolveBody(p);
980
1015
  const email = {
981
1016
  to: normalizeAddressList(p.to) ?? "",
982
1017
  cc: normalizeAddressList(p.cc),
@@ -984,8 +1019,8 @@ export default function gmail(rl) {
984
1019
  replyTo: normalizeAddressList(p.replyTo),
985
1020
  from,
986
1021
  subject: p.subject ?? "",
987
- text: p.text,
988
- html: p.html,
1022
+ text,
1023
+ html,
989
1024
  attachments: p.attachments,
990
1025
  };
991
1026
  // When threading a draft, fetch the last Message-ID in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -63,11 +63,31 @@
63
63
  "typescript": "^5.8.0"
64
64
  },
65
65
  "peerDependencies": {
66
- "playwright": "^1.57.0"
66
+ "@react-email/components": "^1.0.12",
67
+ "@react-email/render": "^2.1.0",
68
+ "playwright": "^1.57.0",
69
+ "react": "^19.2.7",
70
+ "react-dom": "^19.2.7",
71
+ "sucrase": "^3.35.1"
67
72
  },
68
73
  "peerDependenciesMeta": {
74
+ "@react-email/components": {
75
+ "optional": true
76
+ },
77
+ "@react-email/render": {
78
+ "optional": true
79
+ },
69
80
  "playwright": {
70
81
  "optional": true
82
+ },
83
+ "react": {
84
+ "optional": true
85
+ },
86
+ "react-dom": {
87
+ "optional": true
88
+ },
89
+ "sucrase": {
90
+ "optional": true
71
91
  }
72
92
  },
73
93
  "dependencies": {