@agent-native/core 0.49.8 → 0.49.10

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/dist/cli/recap.js CHANGED
@@ -192,6 +192,7 @@ export function writePrVisualRecapReusableCallerWorkflow(baseDir, options = {})
192
192
  return { status: "written", path: rel, existed: false };
193
193
  }
194
194
  const DEFAULT_RECAP_APP_URL = "https://plan.agent-native.com";
195
+ const RECAP_MCP_CLIENT_HEADER = "agent-native-pr-visual-recap";
195
196
  export function normalizeRecapAgent(value) {
196
197
  const agent = (value || "claude").toLowerCase();
197
198
  if (agent === "codex")
@@ -1088,7 +1089,11 @@ export function buildRecapClaudeMcpConfig(appUrl, token) {
1088
1089
  plan: {
1089
1090
  type: "http",
1090
1091
  url,
1091
- headers: { Authorization: "Bearer " + token },
1092
+ headers: {
1093
+ Authorization: "Bearer " + token,
1094
+ "X-Agent-Native-MCP-Client": RECAP_MCP_CLIENT_HEADER,
1095
+ "X-Agent-Native-MCP-Full-Catalog": "1",
1096
+ },
1092
1097
  },
1093
1098
  },
1094
1099
  });
@@ -1105,7 +1110,8 @@ export function buildRecapCodexMcpConfig(appUrl) {
1105
1110
  "url = " +
1106
1111
  JSON.stringify(url) +
1107
1112
  "\n" +
1108
- 'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n');
1113
+ 'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n' +
1114
+ 'http_headers = { "X-Agent-Native-MCP-Client" = "agent-native-pr-visual-recap", "X-Agent-Native-MCP-Full-Catalog" = "1" }\n');
1109
1115
  }
1110
1116
  /**
1111
1117
  * `recap mcp-config` — write the plan MCP client config for the chosen backend,
@@ -1319,6 +1325,7 @@ export function buildRecapPrompt(input) {
1319
1325
  else {
1320
1326
  lines.push("## Publish (this is the only way to produce output)");
1321
1327
  lines.push(`The \`plan\` MCP server is configured for you. Call its tools by name (your host may expose them as \`get-plan-blocks\` / \`create-visual-recap\` or \`mcp__plan__get-plan-blocks\` / \`mcp__plan__create-visual-recap\` — same tools).`);
1328
+ lines.push("This is a one-shot GitHub Actions run. Do not wait, sleep, back off, schedule wakeups, reminders, follow-ups, or retries in another turn. Either publish the recap and write `recap-url.txt` in this process, or report the MCP/tool failure plainly.");
1322
1329
  lines.push("First call `get-plan-blocks`, then call `create-visual-recap`. If `create-visual-recap` is available but `get-plan-blocks` is not, the Plan MCP is connected but the block-registry tool is not visible to this runner. Report that the runner must expose `get-plan-blocks` through the workflow/tool allowlist or compact MCP catalog; do not describe that case as a disconnected Plan MCP.");
1323
1330
  lines.push(`1. Call the **create-visual-recap** tool on the \`plan\` MCP server with grounded MDX derived ONLY from the real diff, passing \`visibility: "org"\` so the recap is published org-scoped (never public) server-side${input.prevPlanId
1324
1331
  ? `, and also passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
@@ -1349,6 +1356,9 @@ export function buildRecapPrompt(input) {
1349
1356
  const MARKER = "<!-- pr-visual-recap -->";
1350
1357
  const RECAP_IMAGE_URL_PATH_PATTERN = /\/_agent-native\/recap-image\/[0-9a-f]{32,128}\.png$/;
1351
1358
  const RECAP_SCREENSHOT_QUERY_PARAM = "recapScreenshot";
1359
+ const RECAP_SCREENSHOT_THEME_QUERY_PARAM = "recapScreenshotTheme";
1360
+ const GITHUB_LIGHT_CANVAS_BACKGROUND = "#ffffff";
1361
+ const GITHUB_DARK_CANVAS_BACKGROUND = "#0d1117";
1352
1362
  function repoParts(repoFullName) {
1353
1363
  const [owner, repo] = repoFullName.split("/");
1354
1364
  if (!owner || !repo)
@@ -1436,6 +1446,14 @@ function originOf(url) {
1436
1446
  return "";
1437
1447
  }
1438
1448
  }
1449
+ function trustedRecapImageUrl(raw, base) {
1450
+ const value = (raw || "").trim();
1451
+ return value &&
1452
+ sameOrigin(value, base) &&
1453
+ RECAP_IMAGE_URL_PATH_PATTERN.test(value)
1454
+ ? value
1455
+ : "";
1456
+ }
1439
1457
  /** Build the sticky comment body from the workflow's environment. */
1440
1458
  export function buildCommentBody(env = process.env) {
1441
1459
  const lines = [MARKER];
@@ -1512,28 +1530,29 @@ export function buildCommentBody(env = process.env) {
1512
1530
  lines.push(diagnostic);
1513
1531
  }
1514
1532
  }
1515
- // Keep a link to the last-good recap so reviewers are not left in the dark.
1516
- if (prevPlanId && base) {
1517
- const prevSafeUrl = `${base}/recaps/${prevPlanId}`;
1518
- lines.push("", `Previous recap (from an earlier push): [Open recap](${prevSafeUrl})`);
1519
- }
1520
1533
  if (markerPlanId)
1521
1534
  lines.push("", `<!-- plan-id: ${markerPlanId} -->`);
1522
1535
  return lines.join("\n");
1523
1536
  }
1524
- // The image URL is produced by our own recap-image route, but validate it is
1537
+ // Image URLs are produced by our own recap-image route, but validate each is
1525
1538
  // same-origin and matches the canonical hex-token path before embedding it, so
1526
- // it likewise cannot inject markdown.
1527
- const imageUrlRaw = (env.RECAP_IMAGE_URL || "").trim();
1528
- const imageUrl = imageUrlRaw &&
1529
- sameOrigin(imageUrlRaw, base) &&
1530
- RECAP_IMAGE_URL_PATH_PATTERN.test(imageUrlRaw)
1531
- ? imageUrlRaw
1532
- : "";
1539
+ // they likewise cannot inject markdown or HTML.
1540
+ const lightImageUrl = trustedRecapImageUrl(env.RECAP_LIGHT_IMAGE_URL || env.RECAP_IMAGE_URL, base);
1541
+ const darkImageUrl = trustedRecapImageUrl(env.RECAP_DARK_IMAGE_URL, base);
1542
+ const fallbackImageUrl = lightImageUrl || darkImageUrl;
1533
1543
  lines.push(`### Here's a [visual recap](${safeUrl}) of what changed:`);
1534
1544
  lines.push("");
1535
- if (imageUrl) {
1536
- lines.push(`[![Visual recap](${imageUrl})](${safeUrl})`);
1545
+ if (lightImageUrl && darkImageUrl) {
1546
+ lines.push(`<a href="${safeUrl}">`);
1547
+ lines.push(`<picture>`);
1548
+ lines.push(` <source media="(prefers-color-scheme: dark)" srcset="${darkImageUrl}">`);
1549
+ lines.push(` <img alt="Visual recap" src="${lightImageUrl}">`);
1550
+ lines.push(`</picture>`);
1551
+ lines.push(`</a>`);
1552
+ lines.push("");
1553
+ }
1554
+ else if (fallbackImageUrl) {
1555
+ lines.push(`[![Visual recap](${fallbackImageUrl})](${safeUrl})`);
1537
1556
  lines.push("");
1538
1557
  }
1539
1558
  lines.push(`**[Open the full interactive recap](${safeUrl})**`);
@@ -1710,10 +1729,25 @@ async function defaultImportPlaywright() {
1710
1729
  return (await import("@playwright/test"));
1711
1730
  }
1712
1731
  }
1713
- export function withRecapScreenshotParams(url) {
1732
+ function parseRecapScreenshotTheme(value) {
1733
+ if (value === undefined)
1734
+ return undefined;
1735
+ if (value === "light" || value === "dark")
1736
+ return value;
1737
+ throw new Error("--theme must be light or dark.");
1738
+ }
1739
+ function recapScreenshotBackground(theme) {
1740
+ return theme === "dark"
1741
+ ? GITHUB_DARK_CANVAS_BACKGROUND
1742
+ : GITHUB_LIGHT_CANVAS_BACKGROUND;
1743
+ }
1744
+ export function withRecapScreenshotParams(url, options = {}) {
1714
1745
  try {
1715
1746
  const parsed = new URL(url);
1716
1747
  parsed.searchParams.set(RECAP_SCREENSHOT_QUERY_PARAM, "1");
1748
+ if (options.theme) {
1749
+ parsed.searchParams.set(RECAP_SCREENSHOT_THEME_QUERY_PARAM, options.theme);
1750
+ }
1717
1751
  return parsed.toString();
1718
1752
  }
1719
1753
  catch {
@@ -1727,6 +1761,7 @@ importPlaywright = defaultImportPlaywright) {
1727
1761
  const out = optionalArg(args, "out") ?? "recap.png";
1728
1762
  const token = optionalArg(args, "token");
1729
1763
  const appUrl = optionalArg(args, "app-url");
1764
+ const theme = parseRecapScreenshotTheme(optionalArg(args, "theme"));
1730
1765
  const done = (obj) => {
1731
1766
  process.stdout.write(`${JSON.stringify(obj)}\n`);
1732
1767
  };
@@ -1752,7 +1787,7 @@ importPlaywright = defaultImportPlaywright) {
1752
1787
  return;
1753
1788
  }
1754
1789
  }
1755
- const captureUrl = withRecapScreenshotParams(url);
1790
+ const captureUrl = withRecapScreenshotParams(url, { theme });
1756
1791
  let chromium;
1757
1792
  try {
1758
1793
  ({ chromium } = await importPlaywright());
@@ -1772,7 +1807,33 @@ importPlaywright = defaultImportPlaywright) {
1772
1807
  const context = await browser.newContext({
1773
1808
  viewport: RECAP_SHOT_VIEWPORT,
1774
1809
  deviceScaleFactor: RECAP_SHOT_DEVICE_SCALE_FACTOR,
1810
+ ...(theme ? { colorScheme: theme } : {}),
1775
1811
  });
1812
+ if (theme) {
1813
+ await context.addInitScript(({ background, nextTheme }) => {
1814
+ const applyTheme = () => {
1815
+ try {
1816
+ window.localStorage.setItem("theme", nextTheme);
1817
+ }
1818
+ catch {
1819
+ /* ignore */
1820
+ }
1821
+ const root = document.documentElement;
1822
+ root.classList.remove("light", "dark");
1823
+ root.classList.add(nextTheme);
1824
+ root.setAttribute("data-theme", nextTheme);
1825
+ root.style.colorScheme = nextTheme;
1826
+ root.style.backgroundColor = background;
1827
+ if (document.body) {
1828
+ document.body.style.backgroundColor = background;
1829
+ }
1830
+ };
1831
+ applyTheme();
1832
+ document.addEventListener("DOMContentLoaded", applyTheme, {
1833
+ once: true,
1834
+ });
1835
+ }, { background: recapScreenshotBackground(theme), nextTheme: theme });
1836
+ }
1776
1837
  if (attachToken) {
1777
1838
  // Attach the bearer ONLY to same-origin requests. Context-wide
1778
1839
  // extraHTTPHeaders would also send it to every cross-origin subresource
@@ -1812,9 +1873,23 @@ importPlaywright = defaultImportPlaywright) {
1812
1873
  }
1813
1874
  }
1814
1875
  await page.waitForTimeout(matched ? 1_200 : 500);
1815
- await page.evaluate(() => {
1876
+ await page.evaluate((background) => {
1816
1877
  document.documentElement.style.zoom = "100%";
1817
- });
1878
+ if (!background)
1879
+ return;
1880
+ const root = document.documentElement;
1881
+ root.style.backgroundColor = background;
1882
+ document.body.style.backgroundColor = background;
1883
+ for (const selector of [
1884
+ ".plans-workspace",
1885
+ "[data-plan-reader]",
1886
+ "[data-plan-document]",
1887
+ ]) {
1888
+ const el = document.querySelector(selector);
1889
+ if (el)
1890
+ el.style.backgroundColor = background;
1891
+ }
1892
+ }, theme ? recapScreenshotBackground(theme) : "");
1818
1893
  const measuredHeight = await page.evaluate((maxHeight) => {
1819
1894
  const readHeights = (selectors) => {
1820
1895
  const result = [];
@@ -1939,18 +2014,13 @@ function recoverRecapFailureEnv(env = process.env) {
1939
2014
  return recovered;
1940
2015
  }
1941
2016
  /**
1942
- * Files that, if a PR touches them, would let that PR rewrite what the trusted
1943
- * recap job runs (the workflow itself, the skill, the local CLI, or any agent
1944
- * config the runner loads) so the whole job is skipped, not just the agent
1945
- * step, to keep untrusted PR code away from the publish/API secrets.
1946
- *
1947
- * The `packages/core/**` rule is scoped to the BuilderIO/agent-native monorepo
1948
- * (where packages/core IS the recap CLI source) so that consumer repos with an
1949
- * unrelated `packages/core/` directory are not silently gated. Pass the
1950
- * `repository` ("owner/name") to apply that scoping; omit it to match the old
1951
- * unconditional behaviour (safe for the gate's self-test).
2017
+ * Files that, if a PR touches them, would let that PR rewrite the workflow,
2018
+ * skill, or agent config the trusted recap job loads. The workflow runs the
2019
+ * recap CLI from trusted base-branch source (or an installed package), so normal
2020
+ * package code such as `packages/core/**` can be recapped without executing
2021
+ * PR-modified CLI code.
1952
2022
  */
1953
- export function isRecapSensitivePath(p, repository) {
2023
+ export function isRecapSensitivePath(p) {
1954
2024
  if (p === ".github/workflows/pr-visual-recap.yml" ||
1955
2025
  /(^|\/)skills\/visual-(recap|plan|plans)\//.test(p) ||
1956
2026
  /(^|\/)\.claude\//.test(p) ||
@@ -1959,12 +2029,6 @@ export function isRecapSensitivePath(p, repository) {
1959
2029
  /(^|\/)\.mcp\.json$/.test(p)) {
1960
2030
  return true;
1961
2031
  }
1962
- // packages/core is the recap-CLI source only in the agent-native monorepo.
1963
- // In consumer repos an unrelated packages/core/ must not gate recaps.
1964
- const isAgentNativeMonorepo = !repository || repository === "BuilderIO/agent-native";
1965
- if (isAgentNativeMonorepo && /(^|\/)packages\/core\//.test(p)) {
1966
- return true;
1967
- }
1968
2032
  return false;
1969
2033
  }
1970
2034
  /**
@@ -2025,11 +2089,11 @@ export function evaluateRecapGate(input) {
2025
2089
  reasons.push("invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})");
2026
2090
  }
2027
2091
  // Self-modifying guard: if this PR changes the workflow, the
2028
- // visual-recap/visual-plan skill, the local CLI (packages/core), or any agent
2029
- // config the runner would load (.claude/**, CLAUDE.md, .mcp.json), skip the
2030
- // ENTIRE job — not just the agent — so a PR can never rewrite what runs
2031
- // (skill, hooks, settings, CLI) and exfiltrate the publish/API secrets.
2032
- const hits = input.changedFiles.filter((p) => isRecapSensitivePath(p, input.repository));
2092
+ // visual-recap/visual-plan skill, or any agent config the runner would load
2093
+ // (.claude/**, CLAUDE.md, .mcp.json), skip the ENTIRE job — not just the
2094
+ // agent — so a PR can never rewrite what runs (skill, hooks, settings) and
2095
+ // exfiltrate the publish/API secrets.
2096
+ const hits = input.changedFiles.filter((p) => isRecapSensitivePath(p));
2033
2097
  if (hits.length) {
2034
2098
  reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(", ")}${hits.length > 3 ? ", …" : ""}) — skipping so untrusted PR code never runs with secrets`);
2035
2099
  }
@@ -2692,7 +2756,7 @@ Usage:
2692
2756
  npx @agent-native/core@latest recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
2693
2757
  npx @agent-native/core@latest recap scan --diff <path>
2694
2758
  npx @agent-native/core@latest recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
2695
- npx @agent-native/core@latest recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png]
2759
+ npx @agent-native/core@latest recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png] [--theme light|dark]
2696
2760
  npx @agent-native/core@latest recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
2697
2761
  npx @agent-native/core@latest recap agent-summary --result-file <path> [--stderr-file <path>] [--exit-code-file <path>] [--agent claude|codex]
2698
2762
  npx @agent-native/core@latest recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
@@ -2716,8 +2780,8 @@ Usage:
2716
2780
  files from the GitHub REST API (paged, with GH_TOKEN/GITHUB_TOKEN). Skips
2717
2781
  drafts, forks, bot authors, the missing-secret case, an invalid agent/model,
2718
2782
  and any PR that touches recap-control files (the workflow, the skill,
2719
- packages/core, .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED
2720
- on any file-list error. Writes run=<true|false> and agent=<claude|codex> to
2783
+ .claude/**, CLAUDE.md, AGENTS.md, .mcp.json) — failing CLOSED on any
2784
+ file-list error. Writes run=<true|false> and agent=<claude|codex> to
2721
2785
  $GITHUB_OUTPUT.
2722
2786
  npx @agent-native/core@latest recap agent-summary
2723
2787
  Read the captured Claude/Codex result file and write a sanitized one-line