@edgehero/pi-dispatch 4.1.0 → 4.3.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.
package/src/triggers.mjs CHANGED
@@ -32,6 +32,8 @@ import { imageRefProblem } from "./image-ref.mjs";
32
32
  import { SKILL_NAME_RE } from "./flow-gate.mjs";
33
33
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
34
34
  import { findDuplicateKey } from "./json-duplicates.mjs";
35
+ // The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
36
+ import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
35
37
  import { validateModelRef } from "./model-ref.mjs";
36
38
  import { parseUsdMicros } from "./money.mjs";
37
39
  import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
@@ -189,6 +191,29 @@ const REVIEW_ACTION = "review_submitted";
189
191
  */
190
192
  // EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
191
193
  export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
194
+ /**
195
+ * The tools a job's MCP brings (REQ-MCP-OPERATOR-SERVERS, image/runner/src/mcp.mjs), excludable only by a trigger that
196
+ * names servers: codemode, tool_search and pi's three resource tools. One server's tools are excluded as a whole by
197
+ * `mcpServerToolPattern(name)`; a single tool of a server is not, since its name (pi's sanitized `mcp__<server>__<tool>`)
198
+ * is unknown until the server lists it, and a misspelled one would exclude nothing. That is the overlay's
199
+ * `toolExposure: "hidden"`, the operator's.
200
+ */
201
+ export const MCP_EXCLUDABLE_TOOL_NAMES = new Set(["codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
202
+ /**
203
+ * `excludeTools` without the entries that name the job's MCP tools, or undefined when nothing is left: what a job that
204
+ * connects no MCP server carries (a chained child, which never inherits `run.mcp`). Its runner knows none of those
205
+ * names, and with no MCP there is nothing for them to remove, so dropping them narrows nothing.
206
+ */
207
+ export function withoutMcpExclusions(excludeTools) {
208
+ if (!Array.isArray(excludeTools)) return excludeTools;
209
+ const kept = excludeTools.filter((name) => !MCP_EXCLUDABLE_TOOL_NAMES.has(name) && !String(name).startsWith("mcp__"));
210
+ return kept.length > 0 ? kept : undefined;
211
+ }
212
+
213
+ /** The excludeTools entry that removes every tool of one named server: pi's namespace, `-` written as `_`. */
214
+ export function mcpServerToolPattern(name) {
215
+ return `mcp__${String(name).replace(/-/g, "_")}__*`;
216
+ }
192
217
 
193
218
  // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
194
219
  // parse; the charset also excludes `:` and the dedicated check names the reason.
@@ -400,6 +425,9 @@ function normalizeCron(on, run, index, path, state) {
400
425
  // `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
401
426
  // today's default must not get a `data` that disagrees with the file they reviewed.
402
427
  const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
428
+ // Called for its refusal only (issue #625): `true` needs run.resume, which cron refuses, so the returned `run`
429
+ // grows no `awaitInput` key, exactly as it grows no `replicas` one.
430
+ validateAwaitInputFlag(run, `cron trigger "${id}"`, path);
403
431
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
404
432
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
405
433
  validateReplicas(run, `cron trigger "${id}"`, path);
@@ -416,6 +444,7 @@ function normalizeCron(on, run, index, path, state) {
416
444
  // A cron job's session narrows like any other: the field selects what the container's agent can do,
417
445
  // which is orthogonal to what triggered it (issue #291).
418
446
  const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
447
+ const mcp = validateMcp(on, run, `cron trigger "${id}"`, path, { kind: "local" });
419
448
  // Validated since #502; before it these three were copied untouched, so `run.provider: 7` loaded.
420
449
  const ref = validateRunModel(on, run, `cron trigger "${id}"`, path);
421
450
  // Issue #505: the portfolio flag. Strictly boolean, the house rule (`run.github`'s reason: a truthy string must not
@@ -430,7 +459,7 @@ function normalizeCron(on, run, index, path, state) {
430
459
  // freeze today's default into every stored repeatable.
431
460
  return {
432
461
  on: { type: "cron", id, pattern },
433
- run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
462
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
434
463
  };
435
464
  }
436
465
 
@@ -531,6 +560,28 @@ function validateResumeFlag(run, at, path) {
531
560
  return run.resume;
532
561
  }
533
562
 
563
+ /**
564
+ * Validate the per-trigger `run.awaitInput` flag (REQ-AWAIT-INPUT, issue #625): whether the job is offered the tool
565
+ * that ends a run to wait for a person's answer. Strictly boolean, validateResumeFlag's rule and for its reason: a
566
+ * truthy `"false"` would arm what the operator meant to leave off.
567
+ *
568
+ * `true` needs `run.resume: true` beside it, and that one rule carries every other refusal: a parked run is only
569
+ * worth anything if the next run resumes its transcript, and `run.resume` is already refused on cron and beside
570
+ * `run.command`, `run.replicas` and `run.secrets`, each for a reason that holds here too. So this adds no list of
571
+ * its own, which is one place fewer for the two to disagree.
572
+ *
573
+ * Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's.
574
+ */
575
+ function validateAwaitInputFlag(run, at, path) {
576
+ if (run.awaitInput !== undefined && typeof run.awaitInput !== "boolean") {
577
+ throw configError(`${at}: run.awaitInput must be true or false when present: ${path}`);
578
+ }
579
+ if (run.awaitInput === true && run.resume !== true) {
580
+ throw configError(`${at}: run.awaitInput needs run.resume: true beside it -- a run that stops to wait for an answer is continued by the next run on the same issue or pull request, and only run.resume keeps the transcript it would continue from: ${path}`);
581
+ }
582
+ return run.awaitInput;
583
+ }
584
+
534
585
  /**
535
586
  * Validate the per-trigger `run.image` reference, shared by all four normalizers. It selects the Docker image
536
587
  * this trigger's job containers run in, overriding the deployment-wide `PI_JOB_IMAGE` for this trigger only;
@@ -1368,14 +1419,22 @@ function validateExcludeTools(on, run, at, path) {
1368
1419
  if (!Array.isArray(excludeTools) || excludeTools.length === 0) {
1369
1420
  throw configError(`${at}: run.excludeTools must be a non-empty array of tool names when present (got ${JSON.stringify(excludeTools)}) -- an empty exclusion is a field that does nothing, and accepted-and-ignored is how an operator comes to trust one that does: ${path}`);
1370
1421
  }
1422
+ // With run.mcp, the tools the job's MCP brings are known too (its shape is the next validator's to judge; here a
1423
+ // name that is not a valid server name simply adds no pattern).
1424
+ const known = new Set(EXCLUDABLE_TOOL_NAMES);
1425
+ if (Array.isArray(run?.mcp) && run.mcp.length > 0) {
1426
+ for (const name of MCP_EXCLUDABLE_TOOL_NAMES) known.add(name);
1427
+ for (const name of run.mcp) if (typeof name === "string" && MCP_SERVER_NAME_RE.test(name)) known.add(mcpServerToolPattern(name));
1428
+ }
1371
1429
  const seen = new Set();
1372
1430
  for (let i = 0; i < excludeTools.length; i++) {
1373
1431
  const name = excludeTools[i];
1374
1432
  if (!isNonEmptyString(name)) {
1375
1433
  throw configError(`${at}: run.excludeTools[${i}] must be a non-empty string naming a pi tool (got ${JSON.stringify(name)}): ${path}`);
1376
1434
  }
1377
- if (!EXCLUDABLE_TOOL_NAMES.has(name)) {
1378
- throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...EXCLUDABLE_TOOL_NAMES].join(", ")}) -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1435
+ if (!known.has(name)) {
1436
+ const mcpHint = MCP_EXCLUDABLE_TOOL_NAMES.has(name) || name.startsWith("mcp__") ? " (an MCP tool is excludable only by a trigger whose run.mcp names its server, and a server's tools only all at once, as mcp__<server>__*)" : "";
1437
+ throw configError(`${at}: run.excludeTools[${i}] ${JSON.stringify(name)} is not a tool the pinned pi knows (known: ${[...known].join(", ")})${mcpHint} -- pi ignores unknown names silently, so a misspelled exclusion would exclude nothing while the file reads as though it did: ${path}`);
1379
1438
  }
1380
1439
  if (seen.has(name)) {
1381
1440
  throw configError(`${at}: run.excludeTools names ${JSON.stringify(name)} twice -- the entries are a removal set, so the second can never change the answer: ${path}`);
@@ -1386,6 +1445,84 @@ function validateExcludeTools(on, run, at, path) {
1386
1445
  return [...excludeTools];
1387
1446
  }
1388
1447
 
1448
+ /** The most MCP servers one trigger may name. A bound, not a budget: each one is a connection the job opens at start. */
1449
+ export const MCP_SERVERS_MAX = 8;
1450
+
1451
+ /** The spellings of `run.mcp` a key is refused for, compared after `mcpKeySkeleton`. */
1452
+ const MCP_NEAR_MISSES = Object.freeze(["mcp", "mcps", "mcpserver", "mcpservers"]);
1453
+
1454
+ /** An ASCII key reduced to its letters and digits, lowercased: `MCP_Servers` and `mcp-servers` both read `mcpservers`. */
1455
+ export function mcpKeySkeleton(key) {
1456
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
1457
+ }
1458
+
1459
+ /**
1460
+ * Whether `key` (not the field itself) is refused as a spelling of `run.mcp` rather than dropped: an ASCII key whose
1461
+ * skeleton is one of the field's spellings, or a key that is not plain ASCII and whose plain letters are all letters
1462
+ * of `mcpservers`. A key in another script can be drawn exactly like this one (Greek, Cyrillic, Cherokee, small
1463
+ * capitals, fullwidth forms), which no table of lookalikes keeps up with, but any such key keeps at most the field's
1464
+ * own letters in ASCII. An unrelated non-ASCII key (`fl\u00f6w`) keeps others and still loads, and a lookalike of
1465
+ * another field (`m\u043edels`) is left to that field's sweep.
1466
+ */
1467
+ export function isMcpNearMiss(key) {
1468
+ if (key === "mcp") return false;
1469
+ if (/^[\x20-\x7E]*$/.test(key)) return MCP_NEAR_MISSES.includes(mcpKeySkeleton(key));
1470
+ return [...key.matchAll(/[a-z]/gi)].every(([letter]) => "mcpservers".includes(letter.toLowerCase()));
1471
+ }
1472
+
1473
+ /**
1474
+ * `run.mcp` -- the overlay MCP servers this trigger's jobs connect (REQ-MCP-OPERATOR-SERVERS), by name.
1475
+ *
1476
+ * NAMES ONLY. A server's definition is the operator's, in the overlay's `mcp.json` (INT-OVERLAY-MCP-CONFIG), so a
1477
+ * trigger edit can choose among the servers the deployment offers and never point a credential at a new host. The
1478
+ * loader is fs-free, so whether a name is in the overlay is checked where the file is read: at the job's pickup,
1479
+ * before anything is spent, and by doctor. Absent means none: a job connects no MCP server unless its trigger says
1480
+ * so. The near-miss sweep is excludeTools': a dropped `run.mcp` runs the job without the servers its flow was written
1481
+ * against, while the file reads as though it named them.
1482
+ *
1483
+ * One name is not the overlay's: `forge` (DES-FORGE-MCP) is the job's own forge, through that forge's MCP server and
1484
+ * the job's own token, and the worker writes its entry. Accepted on a github or gitlab trigger only, and refused here
1485
+ * on the rest, since whether it can ever work is a fact about the kind: a cron job holds no forge token of its own, and
1486
+ * forgejo and azure have no forge MCP server this repository has verified.
1487
+ */
1488
+ function validateMcp(on, run, at, path, { kind }) {
1489
+ for (const [label, source, exactIsLegal] of [
1490
+ ["run", run, true],
1491
+ ["on", on, false],
1492
+ ]) {
1493
+ for (const key of Object.keys(source ?? {})) {
1494
+ if (exactIsLegal ? isMcpNearMiss(key) : key === "mcp" || isMcpNearMiss(key)) {
1495
+ if (!/^[\x20-\x7E]*$/.test(key)) {
1496
+ throw configError(`${at}: ${label}.${JSON.stringify(key)} is not a field: a key outside plain ASCII with no plain letter other than run.mcp's can be drawn exactly like run.mcp, which the loader would drop while the file reads as though it named servers. Write field names in plain ASCII: ${path}`);
1497
+ }
1498
+ throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.mcp? A list of MCP servers the loader drops runs the job without them while the file reads as though it named them, so a near miss is refused rather than dropped: ${path}`);
1499
+ }
1500
+ }
1501
+ }
1502
+ const mcp = run?.mcp;
1503
+ if (mcp === undefined) return undefined;
1504
+ if (!Array.isArray(mcp) || mcp.length === 0) {
1505
+ throw configError(`${at}: run.mcp must be a non-empty array of server names from the overlay's mcp.json when present (got ${JSON.stringify(mcp)}); leave it out for none: ${path}`);
1506
+ }
1507
+ if (mcp.length > MCP_SERVERS_MAX) {
1508
+ throw configError(`${at}: run.mcp names ${mcp.length} servers, more than ${MCP_SERVERS_MAX}: ${path}`);
1509
+ }
1510
+ const seen = new Set();
1511
+ for (let i = 0; i < mcp.length; i++) {
1512
+ const name = mcp[i];
1513
+ if (typeof name !== "string" || !MCP_SERVER_NAME_RE.test(name)) {
1514
+ throw configError(`${at}: run.mcp[${i}] must name a server of the overlay's mcp.json: lowercase letters and digits, words joined by single -, up to 32 (got ${JSON.stringify(name)}): ${path}`);
1515
+ }
1516
+ if (seen.has(name)) throw configError(`${at}: run.mcp names ${JSON.stringify(name)} twice: ${path}`);
1517
+ seen.add(name);
1518
+ if (name === FORGE_MCP_SERVER_NAME && !FORGE_MCP_KINDS.includes(kind)) {
1519
+ const why = kind === "local" ? "a cron job holds no forge token of its own for the server to use" : `${kind} has no forge MCP server this deployment has verified yet`;
1520
+ throw configError(`${at}: run.mcp names "forge", the job's own forge server, which a ${FORGE_MCP_KINDS.join(" or ")} trigger may name and this one may not: ${why}: ${path}`);
1521
+ }
1522
+ }
1523
+ return [...mcp];
1524
+ }
1525
+
1389
1526
  /**
1390
1527
  * `run.waitFor` -- the conditions that must all clear before this trigger's job starts (issue #230).
1391
1528
  *
@@ -1537,6 +1674,7 @@ function normalizeLabel(on, run, index, path) {
1537
1674
  const skillsDir = validateSkillsDir(run, at, path);
1538
1675
  const instructions = validateInstructions(run, at, path);
1539
1676
  const resume = validateResumeFlag(run, at, path);
1677
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1540
1678
  const repository = validateRepository(run, "label", at, path);
1541
1679
  const replicas = validateReplicas(run, at, path);
1542
1680
  const secrets = validateSecrets(run, at, path);
@@ -1544,10 +1682,11 @@ function normalizeLabel(on, run, index, path) {
1544
1682
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1545
1683
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1546
1684
  const excludeTools = validateExcludeTools(on, run, at, path);
1685
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1547
1686
  const ref = validateRunModel(on, run, at, path);
1548
1687
  return {
1549
1688
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
1550
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1689
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1551
1690
  };
1552
1691
  }
1553
1692
 
@@ -1579,6 +1718,7 @@ function normalizeComment(on, run, index, path, state) {
1579
1718
  const skillsDir = validateSkillsDir(run, at, path);
1580
1719
  const instructions = validateInstructions(run, at, path);
1581
1720
  const resume = validateResumeFlag(run, at, path);
1721
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1582
1722
  const repository = validateRepository(run, "comment", at, path);
1583
1723
  const replicas = validateReplicas(run, at, path);
1584
1724
  const secrets = validateSecrets(run, at, path);
@@ -1586,10 +1726,11 @@ function normalizeComment(on, run, index, path, state) {
1586
1726
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1587
1727
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1588
1728
  const excludeTools = validateExcludeTools(on, run, at, path);
1729
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1589
1730
  const ref = validateRunModel(on, run, at, path);
1590
1731
  return {
1591
1732
  on: { type: "comment", phrase: on.phrase },
1592
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1733
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1593
1734
  };
1594
1735
  }
1595
1736
 
@@ -1648,6 +1789,7 @@ function normalizeIssue(on, run, index, path) {
1648
1789
  const skillsDir = validateSkillsDir(run, at, path);
1649
1790
  const instructions = validateInstructions(run, at, path);
1650
1791
  const resume = validateResumeFlag(run, at, path);
1792
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1651
1793
  validateRepository(run, "issue", at, path);
1652
1794
  const replicas = validateReplicas(run, at, path);
1653
1795
  const secrets = validateSecrets(run, at, path);
@@ -1655,6 +1797,7 @@ function normalizeIssue(on, run, index, path) {
1655
1797
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1656
1798
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1657
1799
  const excludeTools = validateExcludeTools(on, run, at, path);
1800
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1658
1801
  const ref = validateRunModel(on, run, at, path);
1659
1802
  return {
1660
1803
  on: {
@@ -1665,7 +1808,7 @@ function normalizeIssue(on, run, index, path) {
1665
1808
  ...(number !== undefined && { number }),
1666
1809
  ...(once !== undefined && { once }),
1667
1810
  },
1668
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1811
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1669
1812
  };
1670
1813
  }
1671
1814
 
@@ -1735,6 +1878,7 @@ function normalizePullRequest(on, run, index, path) {
1735
1878
  const skillsDir = validateSkillsDir(run, at, path);
1736
1879
  const instructions = validateInstructions(run, at, path);
1737
1880
  const resume = validateResumeFlag(run, at, path);
1881
+ const awaitInput = validateAwaitInputFlag(run, at, path);
1738
1882
  validateRepository(run, "pull_request", at, path);
1739
1883
  const replicas = validateReplicas(run, at, path);
1740
1884
  const secrets = validateSecrets(run, at, path);
@@ -1742,6 +1886,7 @@ function normalizePullRequest(on, run, index, path) {
1742
1886
  const backend = validateBackend(on, run, at, path, { localWorkspace: false });
1743
1887
  const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
1744
1888
  const excludeTools = validateExcludeTools(on, run, at, path);
1889
+ const mcp = validateMcp(on, run, at, path, { kind: run.kind });
1745
1890
  const ref = validateRunModel(on, run, at, path);
1746
1891
  return {
1747
1892
  on: {
@@ -1757,7 +1902,7 @@ function normalizePullRequest(on, run, index, path) {
1757
1902
  ...(number !== undefined && { number }),
1758
1903
  ...(once !== undefined && { once }),
1759
1904
  },
1760
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
1905
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
1761
1906
  };
1762
1907
  }
1763
1908