@agent-native/core 0.49.22 → 0.49.23

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 (71) hide show
  1. package/dist/agent/production-agent.d.ts +1 -0
  2. package/dist/agent/production-agent.d.ts.map +1 -1
  3. package/dist/agent/production-agent.js +15 -0
  4. package/dist/agent/production-agent.js.map +1 -1
  5. package/dist/agent/tool-search.d.ts.map +1 -1
  6. package/dist/agent/tool-search.js +32 -7
  7. package/dist/agent/tool-search.js.map +1 -1
  8. package/dist/cli/connect.d.ts +2 -3
  9. package/dist/cli/connect.d.ts.map +1 -1
  10. package/dist/cli/connect.js +60 -37
  11. package/dist/cli/connect.js.map +1 -1
  12. package/dist/cli/pr-visual-recap-workflow.d.ts +5 -7
  13. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  14. package/dist/cli/pr-visual-recap-workflow.js +5 -7
  15. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  16. package/dist/cli/recap.d.ts +44 -52
  17. package/dist/cli/recap.d.ts.map +1 -1
  18. package/dist/cli/recap.js +420 -414
  19. package/dist/cli/recap.js.map +1 -1
  20. package/dist/client/AssistantChat.d.ts +6 -3
  21. package/dist/client/AssistantChat.d.ts.map +1 -1
  22. package/dist/client/AssistantChat.js +1 -1
  23. package/dist/client/AssistantChat.js.map +1 -1
  24. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  25. package/dist/client/MultiTabAssistantChat.js +23 -3
  26. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  27. package/dist/client/agent-chat.d.ts +8 -0
  28. package/dist/client/agent-chat.d.ts.map +1 -1
  29. package/dist/client/agent-chat.js +24 -1
  30. package/dist/client/agent-chat.js.map +1 -1
  31. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  32. package/dist/client/blocks/library/AnnotatedCodeBlock.js +4 -1
  33. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  34. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  35. package/dist/client/blocks/library/DiffBlock.js +20 -7
  36. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  37. package/dist/client/blocks/library/annotation-rail.js +5 -5
  38. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  39. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  40. package/dist/client/composer/TiptapComposer.js +15 -2
  41. package/dist/client/composer/TiptapComposer.js.map +1 -1
  42. package/dist/coding-tools/run-code.d.ts.map +1 -1
  43. package/dist/coding-tools/run-code.js +69 -17
  44. package/dist/coding-tools/run-code.js.map +1 -1
  45. package/dist/integrations/plugin.d.ts.map +1 -1
  46. package/dist/integrations/plugin.js +2 -0
  47. package/dist/integrations/plugin.js.map +1 -1
  48. package/dist/mcp/build-server.d.ts +12 -10
  49. package/dist/mcp/build-server.d.ts.map +1 -1
  50. package/dist/mcp/build-server.js +53 -89
  51. package/dist/mcp/build-server.js.map +1 -1
  52. package/dist/mcp/connect-route.d.ts.map +1 -1
  53. package/dist/mcp/connect-route.js +5 -4
  54. package/dist/mcp/connect-route.js.map +1 -1
  55. package/dist/mcp/oauth-token.d.ts +6 -5
  56. package/dist/mcp/oauth-token.d.ts.map +1 -1
  57. package/dist/mcp/oauth-token.js.map +1 -1
  58. package/dist/mcp/stdio.d.ts.map +1 -1
  59. package/dist/mcp/stdio.js +9 -2
  60. package/dist/mcp/stdio.js.map +1 -1
  61. package/dist/provider-api/staging.d.ts.map +1 -1
  62. package/dist/provider-api/staging.js +6 -4
  63. package/dist/provider-api/staging.js.map +1 -1
  64. package/dist/server/agent-chat-plugin.d.ts +10 -7
  65. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  66. package/dist/server/agent-chat-plugin.js.map +1 -1
  67. package/docs/content/actions.md +1 -1
  68. package/docs/content/external-agents.md +53 -40
  69. package/docs/content/mcp-protocol.md +16 -11
  70. package/docs/content/pr-visual-recap.md +1 -1
  71. package/package.json +1 -1
package/dist/cli/recap.js CHANGED
@@ -14,13 +14,12 @@
14
14
  * collect-diff Collect the bounded base...head diff (excluding lockfiles,
15
15
  * build output, snapshots), cap it at ~600KB, and classify the
16
16
  * huge/tiny flags.
17
- * mcp-config Write the plan MCP client config for the chosen backend
18
- * (Claude Code JSON or Codex config.toml).
19
- * mcp-smoke Verify the configured Plan MCP endpoint exposes the publish
20
- * tools before spending runner time on Claude/Codex.
21
17
  * scan Refuse to hand a secret-leaking diff to the agent.
18
+ * block-reference
19
+ * Fetch the live get-plan-blocks reference for the target app.
22
20
  * build-prompt Assemble the agent prompt = latest visual-recap skill bundle
23
21
  * + a task wrapper (or repo-pinned skill with --skill-source).
22
+ * publish Publish the agent-authored recap-source.json over HTTP.
24
23
  * shot Screenshot the published plan and upload it to the plan app's
25
24
  * signed public image route (for an inline PR-comment image).
26
25
  * usage Parse and emit agent token-usage/cost from stdout.
@@ -36,7 +35,6 @@
36
35
  */
37
36
  import { execFileSync } from "node:child_process";
38
37
  import fs from "node:fs";
39
- import os from "node:os";
40
38
  import path from "node:path";
41
39
  import { readPlanPublishAuth } from "./plan-publish-store.js";
42
40
  import { PR_VISUAL_RECAP_WORKFLOW_YML } from "./pr-visual-recap-workflow.js";
@@ -196,12 +194,6 @@ export function writePrVisualRecapReusableCallerWorkflow(baseDir, options = {})
196
194
  return { status: "written", path: rel, existed: false };
197
195
  }
198
196
  const DEFAULT_RECAP_APP_URL = "https://plan.agent-native.com";
199
- const RECAP_MCP_CLIENT_HEADER = "agent-native-pr-visual-recap";
200
- export const RECAP_MCP_REQUIRED_TOOLS = [
201
- "get-plan-blocks",
202
- "create-visual-recap",
203
- "set-resource-visibility",
204
- ];
205
197
  export function normalizeRecapAgent(value) {
206
198
  const agent = (value || "claude").toLowerCase();
207
199
  if (agent === "codex")
@@ -1109,371 +1101,6 @@ function runCollectDiff(args) {
1109
1101
  process.stdout.write(`${JSON.stringify({ bytes, changed, huge, tiny })}\n`);
1110
1102
  }
1111
1103
  /* -------------------------------------------------------------------------- */
1112
- /* MCP config writers — were the two `node -e` one-liners in the agent steps */
1113
- /* -------------------------------------------------------------------------- */
1114
- /**
1115
- * The Claude Code MCP config the recap agent loads: a single HTTP `plan` server
1116
- * pointing at the app's `/_agent-native/mcp` endpoint, authorized with the
1117
- * PLAN_RECAP_TOKEN. Pure (returns the JSON string) so it can be unit-tested.
1118
- */
1119
- export function buildRecapClaudeMcpConfig(appUrl, token) {
1120
- const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1121
- return JSON.stringify({
1122
- mcpServers: {
1123
- plan: {
1124
- type: "http",
1125
- url,
1126
- headers: {
1127
- Authorization: "Bearer " + token,
1128
- "X-Agent-Native-MCP-Client": RECAP_MCP_CLIENT_HEADER,
1129
- "X-Agent-Native-MCP-Full-Catalog": "1",
1130
- },
1131
- },
1132
- "agent-native-plans": {
1133
- type: "http",
1134
- url,
1135
- headers: {
1136
- Authorization: "Bearer " + token,
1137
- "X-Agent-Native-MCP-Client": RECAP_MCP_CLIENT_HEADER,
1138
- "X-Agent-Native-MCP-Full-Catalog": "1",
1139
- },
1140
- },
1141
- },
1142
- });
1143
- }
1144
- /**
1145
- * The Codex `config.toml` the recap agent loads. JSON.stringify the URL value so
1146
- * a stray quote/newline in the app URL can't break out of the TOML basic string
1147
- * (TOML shares JSON's escaping); the key and env-var name stay literal. Pure so
1148
- * it can be unit-tested.
1149
- */
1150
- export function buildRecapCodexMcpConfig(appUrl) {
1151
- const url = appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1152
- return ("[mcp_servers.plan]\n" +
1153
- "url = " +
1154
- JSON.stringify(url) +
1155
- "\n" +
1156
- 'bearer_token_env_var = "PLAN_RECAP_TOKEN"\n' +
1157
- 'http_headers = { "X-Agent-Native-MCP-Client" = "agent-native-pr-visual-recap", "X-Agent-Native-MCP-Full-Catalog" = "1" }\n');
1158
- }
1159
- /**
1160
- * Per-attempt timeout for the non-mutating MCP smoke probe. Short enough that
1161
- * the workflow's 3-attempt retry loop can recover from a cold-start hang within
1162
- * the job budget, long enough not to flake on a warm-but-slow response. The
1163
- * plan app is serverless and intermittently 404s / stalls during a cold start
1164
- * or mid-deploy; without a bound, undici's multi-minute default header/body
1165
- * timeout would burn the whole job on a single stuck attempt.
1166
- */
1167
- const RECAP_MCP_SMOKE_TIMEOUT_MS = 15_000;
1168
- function recapMcpUrl(appUrl) {
1169
- return appUrl.replace(/\/$/, "") + "/_agent-native/mcp";
1170
- }
1171
- function recapMcpSmokeHeaders(token) {
1172
- return {
1173
- authorization: `Bearer ${token}`,
1174
- accept: "application/json, text/event-stream",
1175
- "content-type": "application/json",
1176
- "mcp-protocol-version": "2025-06-18",
1177
- "x-agent-native-mcp-client": RECAP_MCP_CLIENT_HEADER,
1178
- "x-agent-native-mcp-full-catalog": "1",
1179
- };
1180
- }
1181
- function parseSseJsonPayload(raw) {
1182
- const dataLines = raw
1183
- .split(/\r?\n/)
1184
- .map((line) => line.trim())
1185
- .filter((line) => line.startsWith("data:"))
1186
- .map((line) => line.slice("data:".length).trim())
1187
- .filter((line) => line && line !== "[DONE]");
1188
- for (let i = dataLines.length - 1; i >= 0; i -= 1) {
1189
- try {
1190
- return JSON.parse(dataLines[i]);
1191
- }
1192
- catch {
1193
- // Keep looking for a parseable event payload.
1194
- }
1195
- }
1196
- return null;
1197
- }
1198
- function parseMcpJsonPayload(raw) {
1199
- const trimmed = raw.trim();
1200
- if (!trimmed)
1201
- return null;
1202
- try {
1203
- return JSON.parse(trimmed);
1204
- }
1205
- catch {
1206
- return parseSseJsonPayload(trimmed);
1207
- }
1208
- }
1209
- function mcpRpcErrorMessage(payload) {
1210
- if (!payload || typeof payload !== "object")
1211
- return null;
1212
- const error = payload.error;
1213
- if (!error || typeof error !== "object")
1214
- return null;
1215
- const message = error.message;
1216
- return typeof message === "string" && message.trim()
1217
- ? message.trim()
1218
- : JSON.stringify(error);
1219
- }
1220
- async function postRecapMcpRpc(input) {
1221
- const response = await input.fetchFn(input.mcpUrl, {
1222
- method: "POST",
1223
- headers: recapMcpSmokeHeaders(input.token),
1224
- body: JSON.stringify({
1225
- jsonrpc: "2.0",
1226
- id: input.id,
1227
- method: input.method,
1228
- params: input.params ?? {},
1229
- }),
1230
- // Bound each attempt so a cold-start hang fails fast and the workflow's
1231
- // smoke retry loop can re-probe a (by-then warm) endpoint, instead of
1232
- // blocking on undici's multi-minute default timeout.
1233
- signal: AbortSignal.timeout(RECAP_MCP_SMOKE_TIMEOUT_MS),
1234
- });
1235
- const raw = await response.text().catch((err) => String(err));
1236
- if (!response.ok) {
1237
- const detail = sanitizeAgentFailureSummary(raw, 300);
1238
- return {
1239
- ok: false,
1240
- status: response.status,
1241
- raw,
1242
- reason: `Plan MCP ${input.method} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
1243
- };
1244
- }
1245
- const payload = parseMcpJsonPayload(raw);
1246
- if (!payload) {
1247
- return {
1248
- ok: false,
1249
- raw,
1250
- reason: `Plan MCP ${input.method} returned an unreadable response`,
1251
- };
1252
- }
1253
- const rpcError = mcpRpcErrorMessage(payload);
1254
- if (rpcError) {
1255
- return {
1256
- ok: false,
1257
- raw,
1258
- reason: `Plan MCP ${input.method} failed: ${sanitizeAgentFailureSummary(rpcError, 300)}`,
1259
- };
1260
- }
1261
- return { ok: true, payload };
1262
- }
1263
- function extractMcpToolNames(payload) {
1264
- const result = payload && typeof payload === "object"
1265
- ? payload.result
1266
- : undefined;
1267
- const tools = result && typeof result === "object"
1268
- ? result.tools
1269
- : undefined;
1270
- if (!Array.isArray(tools))
1271
- return [];
1272
- return tools
1273
- .map((tool) => tool && typeof tool === "object"
1274
- ? tool.name
1275
- : undefined)
1276
- .filter((name) => typeof name === "string" && !!name)
1277
- .sort();
1278
- }
1279
- function recapMcpSmokeFailure(input) {
1280
- const tools = input.tools ?? [];
1281
- const reason = sanitizeAgentFailureSummary(input.reason, 500);
1282
- return {
1283
- ok: false,
1284
- appUrl: input.appUrl,
1285
- mcpUrl: input.mcpUrl,
1286
- toolCount: tools.length,
1287
- tools,
1288
- requiredTools: [...input.requiredTools],
1289
- reason,
1290
- summary: `Plan MCP smoke check failed: ${reason}`,
1291
- };
1292
- }
1293
- /**
1294
- * Non-mutating live contract check for PR Visual Recap publishing.
1295
- *
1296
- * The previous workflow discovered a broken Plan MCP catalog only after the
1297
- * agent tried to publish and failed to create `recap-url.txt`. This probes the
1298
- * same authenticated MCP endpoint first and requires the three tools that the
1299
- * visual-recap skill needs to publish a hosted recap.
1300
- */
1301
- export async function smokeRecapMcpTools(input) {
1302
- const appUrl = input.appUrl ?? DEFAULT_RECAP_APP_URL;
1303
- const mcpUrl = recapMcpUrl(appUrl);
1304
- const requiredTools = input.requiredTools ?? RECAP_MCP_REQUIRED_TOOLS;
1305
- const token = input.token?.trim();
1306
- if (!token) {
1307
- return recapMcpSmokeFailure({
1308
- appUrl,
1309
- mcpUrl,
1310
- requiredTools,
1311
- reason: "PLAN_RECAP_TOKEN is empty",
1312
- });
1313
- }
1314
- const fetchFn = input.fetchFn ?? fetch;
1315
- try {
1316
- const init = await postRecapMcpRpc({
1317
- fetchFn,
1318
- mcpUrl,
1319
- token,
1320
- method: "initialize",
1321
- id: 1,
1322
- params: {
1323
- protocolVersion: "2025-06-18",
1324
- capabilities: {},
1325
- clientInfo: {
1326
- name: RECAP_MCP_CLIENT_HEADER,
1327
- version: "1.0.0",
1328
- },
1329
- },
1330
- });
1331
- if (!init.ok) {
1332
- return recapMcpSmokeFailure({
1333
- appUrl,
1334
- mcpUrl,
1335
- requiredTools,
1336
- reason: init.reason,
1337
- });
1338
- }
1339
- const listed = await postRecapMcpRpc({
1340
- fetchFn,
1341
- mcpUrl,
1342
- token,
1343
- method: "tools/list",
1344
- id: 2,
1345
- });
1346
- if (!listed.ok) {
1347
- return recapMcpSmokeFailure({
1348
- appUrl,
1349
- mcpUrl,
1350
- requiredTools,
1351
- reason: listed.reason,
1352
- });
1353
- }
1354
- const tools = extractMcpToolNames(listed.payload);
1355
- const missing = requiredTools.filter((name) => !tools.includes(name));
1356
- if (missing.length > 0) {
1357
- return recapMcpSmokeFailure({
1358
- appUrl,
1359
- mcpUrl,
1360
- tools,
1361
- requiredTools,
1362
- reason: `Plan MCP tools/list returned ${tools.length} tools but is missing required publishing tools: ${missing.join(", ")}`,
1363
- });
1364
- }
1365
- return {
1366
- ok: true,
1367
- appUrl,
1368
- mcpUrl,
1369
- toolCount: tools.length,
1370
- tools,
1371
- requiredTools: [...requiredTools],
1372
- summary: `Plan MCP smoke check passed: tools/list exposes ${requiredTools.join(", ")}.`,
1373
- };
1374
- }
1375
- catch (err) {
1376
- return recapMcpSmokeFailure({
1377
- appUrl,
1378
- mcpUrl,
1379
- requiredTools,
1380
- reason: `Plan MCP smoke check could not reach ${mcpUrl}: ${String(err)}`,
1381
- });
1382
- }
1383
- }
1384
- async function runMcpSmoke(args) {
1385
- const appUrl = optionalArg(args, "app-url") ??
1386
- process.env.PLAN_RECAP_APP_URL ??
1387
- DEFAULT_RECAP_APP_URL;
1388
- const token = optionalArg(args, "token") ?? process.env.PLAN_RECAP_TOKEN;
1389
- const result = await smokeRecapMcpTools({ appUrl, token });
1390
- writeGitHubOutput("ok", result.ok ? "true" : "false");
1391
- writeGitHubOutput("summary", result.summary);
1392
- process.stdout.write(`${JSON.stringify(result)}\n`);
1393
- if (!result.ok)
1394
- process.exitCode = 1;
1395
- }
1396
- /**
1397
- * `recap mcp-config` — write the plan MCP client config for the chosen backend,
1398
- * replacing the two `node -e '...'` one-liners that previously lived inline in
1399
- * the agent steps. PLAN_RECAP_TOKEN is read from the environment (claude only),
1400
- * exactly as before.
1401
- */
1402
- function runMcpConfig(args) {
1403
- const agent = stringArg(args, "agent").toLowerCase();
1404
- const appUrl = stringArg(args, "app-url");
1405
- const force = Boolean(args["force"]);
1406
- if (agent === "claude") {
1407
- const token = process.env.PLAN_RECAP_TOKEN;
1408
- if (!token) {
1409
- process.stderr.write(`recap mcp-config: PLAN_RECAP_TOKEN is not set.\n` +
1410
- `Set it in the workflow environment before running this step.\n`);
1411
- process.exit(1);
1412
- }
1413
- const out = stringArg(args, "out");
1414
- fs.writeFileSync(path.resolve(out), buildRecapClaudeMcpConfig(appUrl, token));
1415
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
1416
- return;
1417
- }
1418
- if (agent === "codex") {
1419
- const out = optionalArg(args, "out") ??
1420
- path.join(os.homedir(), ".codex", "config.toml");
1421
- const absOut = path.resolve(out);
1422
- fs.mkdirSync(path.dirname(absOut), { recursive: true });
1423
- const newEntry = buildRecapCodexMcpConfig(appUrl);
1424
- const SECTION_MARKER = "[mcp_servers.plan]";
1425
- // If the file already exists and is non-empty, merge rather than overwrite.
1426
- let existing = "";
1427
- try {
1428
- const raw = fs.readFileSync(absOut, "utf8");
1429
- if (raw.trim())
1430
- existing = raw;
1431
- }
1432
- catch {
1433
- /* file absent — write fresh */
1434
- }
1435
- if (existing) {
1436
- if (existing.includes(SECTION_MARKER)) {
1437
- // Section already present — skip unless --force was passed.
1438
- if (!force) {
1439
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out, skipped: true, reason: "plan entry already present; pass --force to overwrite" })}\n`);
1440
- return;
1441
- }
1442
- // --force: replace the existing [mcp_servers.plan] block.
1443
- // Remove lines from the section header until the next `[` header or EOF.
1444
- const lines = existing.split("\n");
1445
- const startIdx = lines.findIndex((l) => l.trim() === SECTION_MARKER);
1446
- let endIdx = lines.length;
1447
- for (let i = startIdx + 1; i < lines.length; i++) {
1448
- if (lines[i].trimStart().startsWith("[")) {
1449
- endIdx = i;
1450
- break;
1451
- }
1452
- }
1453
- const without = [
1454
- ...lines.slice(0, startIdx),
1455
- ...lines.slice(endIdx),
1456
- ].join("\n");
1457
- const merged = (without.trimEnd() ? without.trimEnd() + "\n\n" : "") + newEntry;
1458
- fs.writeFileSync(absOut, merged, { mode: 0o600 });
1459
- }
1460
- else {
1461
- // Append the new section to the existing config.
1462
- const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1463
- fs.writeFileSync(absOut, existing + separator + newEntry, {
1464
- mode: 0o600,
1465
- });
1466
- }
1467
- }
1468
- else {
1469
- fs.writeFileSync(absOut, newEntry);
1470
- }
1471
- process.stdout.write(`${JSON.stringify({ ok: true, agent, out })}\n`);
1472
- return;
1473
- }
1474
- throw new Error(`Unknown --agent "${agent}" (expected "claude" or "codex")`);
1475
- }
1476
- /* -------------------------------------------------------------------------- */
1477
1104
  /* Prompt builder — repo SKILL.md + task wrapper */
1478
1105
  /* -------------------------------------------------------------------------- */
1479
1106
  /**
@@ -1591,6 +1218,9 @@ export function buildRecapPrompt(input) {
1591
1218
  }
1592
1219
  if (input.statPath)
1593
1220
  lines.push(`- Diff stat: \`${input.statPath}\` (read this file)`);
1221
+ if (!input.localFiles) {
1222
+ lines.push(`- Live plan block reference: \`${input.blockReferencePath ?? "recap-blocks.md"}\` (read this before authoring; it is the workflow-fetched \`get-plan-blocks\` output for the target Plan app).`);
1223
+ }
1594
1224
  if (input.huge) {
1595
1225
  lines.push(`- The diff is LARGE — produce a **summarized** recap (top files + schema/API deltas), not an exhaustive one. The diff was truncated at the size cap — \`${input.statPath ?? "recap.stat"}\` contains the complete file list with per-file stats; for any file missing from \`${input.diffPath}\`, fetch it directly with \`git diff <base>...<head> -- <path>\`.`);
1596
1226
  }
@@ -1603,21 +1233,18 @@ export function buildRecapPrompt(input) {
1603
1233
  lines.push("3. Write the returned `url` from that command to `recap-url.txt` at the repo root, containing exactly one line. This file is the workflow's only hand-off.");
1604
1234
  }
1605
1235
  else {
1606
- lines.push("## Publish (this is the only way to produce output)");
1607
- lines.push(`The \`plan\` MCP server is configured for you, with \`agent-native-plans\` as a legacy alias. Call its tools by name (your host may expose them as \`get-plan-blocks\` / \`create-visual-recap\`, \`mcp__plan__get-plan-blocks\` / \`mcp__plan__create-visual-recap\`, or \`mcp__agent-native-plans__get-plan-blocks\` / \`mcp__agent-native-plans__create-visual-recap\` same tools).`);
1608
- 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.");
1609
- 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.");
1610
- 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
1611
- ? `, and also passing \`planId: "${input.prevPlanId}"\` so this REPLACES the existing recap plan`
1612
- : ""}${prSourceUrl
1613
- ? `, and also passing \`sourceUrl: "${prSourceUrl}"\` so the hosted recap page can link back to the PR`
1614
- : ""}.`);
1615
- lines.push("If `create-visual-recap` returns validation feedback about empty or invalid wireframes, make one immediate correction pass in this same process: revise the named WireframeBlock/Artboard MDX so each frame has real visible product text/controls, then call `create-visual-recap` again. Do not write `recap-url.txt` until the tool succeeds.");
1616
- lines.push(`2. Write the plan URL to a file named \`recap-url.txt\` at the repo root, containing exactly one line: \`${appUrl}/recaps/<the returned plan id>\`. This file is the workflow's only hand-off — do not print anything else as the deliverable.`);
1617
- lines.push(`3. (Fallback only — skip if step 1 succeeded) If \`create-visual-recap\` does not accept a \`visibility\` parameter (older server), call the **set-resource-visibility** tool with \`{ resourceType: "plan", resourceId: <the returned plan id>, visibility: "org" }\` after publishing.`);
1236
+ lines.push("## Author Source (this is the only way to produce output)");
1237
+ lines.push(`The workflow has already fetched the live \`get-plan-blocks\` output into \`${input.blockReferencePath ?? "recap-blocks.md"}\`. Read that file and treat it as the authoritative block/tag/schema reference for this run.`);
1238
+ lines.push("Do NOT call the Plan MCP server and do NOT try to publish the recap yourself. CI publishes deterministically after you write the source file, which avoids host MCP registration flake.");
1239
+ 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 write `recap-source.json` in this process, or report why source authoring failed plainly.");
1240
+ lines.push("1. Author grounded MDX recap source derived ONLY from the real diff. The final file must be valid JSON, not Markdown, not prose, and not a tool-call transcript.");
1241
+ lines.push('2. Write a file named `recap-source.json` at the repo root with exactly this shape: `{ "title": string, "brief": string, "mdx": { "plan.mdx": string, "canvas.mdx"?: string, "prototype.mdx"?: string, ".plan-state.json"?: string, "assets/"?: { [filename: string]: string } } }`.');
1242
+ lines.push("3. Do not write `recap-url.txt`; the deterministic CLI publisher writes that after it successfully POSTs your source to `create-visual-recap`.");
1618
1243
  }
1619
1244
  lines.push("");
1620
- lines.push("Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still publish a minimal recap and write recap-url.txt. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always publish.)");
1245
+ lines.push(input.localFiles
1246
+ ? "Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still create a minimal local recap and write recap-url.txt from the local preview command. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always produce output.)"
1247
+ : "Do not invent file names, schema fields, or endpoints. Redact anything that looks like a secret. If the diff has no reviewable substance, still write a minimal `recap-source.json`. (CI already gated tiny diffs before invoking you — ignore the skill's advice to skip small diffs; always produce output.)");
1621
1248
  lines.push("");
1622
1249
  lines.push("## Depth preflight");
1623
1250
  lines.push("Before authoring the recap, read the diff/stat and make a quick surface/state inventory of changed files, routes/actions, rendered UI surfaces, popovers/dialogs, role/access states, empty/error states, and shared abstractions. The published recap must cover each meaningful item with a structured block or intentionally omit it because it is tiny, redundant, or not user-visible.");
@@ -1625,7 +1252,9 @@ export function buildRecapPrompt(input) {
1625
1252
  lines.push("");
1626
1253
  lines.push("---");
1627
1254
  lines.push("");
1628
- lines.push("# visual-recap skill (follow this exactly)");
1255
+ lines.push("# visual-recap skill use for recap CONTENT and structure");
1256
+ lines.push("");
1257
+ lines.push("Follow the skill below for WHAT makes a good recap: which blocks to use, grounding, house style, and review depth. IGNORE its publishing and hand-off instructions — in this run you have NO Plan MCP tools and must NOT publish the recap yourself. Publishing is handled exactly as described above (write the source file; CI publishes it deterministically).");
1629
1258
  lines.push("");
1630
1259
  lines.push(input.skillMd.trim());
1631
1260
  lines.push("");
@@ -1664,6 +1293,20 @@ async function githubRequest(token, apiPath, init = {}, fetchFn = fetch) {
1664
1293
  return undefined;
1665
1294
  return (await res.json());
1666
1295
  }
1296
+ export async function isPullRequestHeadCurrent(input) {
1297
+ const expected = input.headSha.trim();
1298
+ if (!expected)
1299
+ return null;
1300
+ const fn = input.fetchFn ?? fetch;
1301
+ try {
1302
+ const pr = await githubRequest(input.token, `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls/${encodeURIComponent(input.issue)}`, {}, fn);
1303
+ const current = pr.head?.sha?.trim();
1304
+ return current ? current === expected : null;
1305
+ }
1306
+ catch {
1307
+ return null;
1308
+ }
1309
+ }
1667
1310
  export async function findExistingComment(input) {
1668
1311
  const fn = input.fetchFn ?? fetch;
1669
1312
  for (let page = 1;; page += 1) {
@@ -1738,6 +1381,10 @@ function trustedRecapImageUrl(raw, base) {
1738
1381
  /** Build the sticky comment body from the workflow's environment. */
1739
1382
  export function buildCommentBody(env = process.env) {
1740
1383
  const lines = [MARKER];
1384
+ const headSha = (env.HEAD_SHA || "").trim();
1385
+ const headMarker = /^[a-f0-9]{7,64}$/i.test(headSha)
1386
+ ? `<!-- head-sha: ${headSha} -->`
1387
+ : "";
1741
1388
  // Last-known plan id threaded from the previous run (supplied via PREV_PLAN_ID
1742
1389
  // when the comment is rebuilt from scratch, or parsed from the env on upsert).
1743
1390
  // We always emit the plan-id marker when any plan id is known so that a
@@ -1760,6 +1407,8 @@ export function buildCommentBody(env = process.env) {
1760
1407
  lines.push(`Reason: \`${reason}\`.`);
1761
1408
  if (prevPlanId)
1762
1409
  lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
1410
+ if (headMarker)
1411
+ lines.push("", headMarker);
1763
1412
  return lines.join("\n");
1764
1413
  }
1765
1414
  // Tiny diffs aren't worth a recap. The workflow upserts this state as a sticky
@@ -1771,6 +1420,8 @@ export function buildCommentBody(env = process.env) {
1771
1420
  lines.push("The change in this pull request is too small to be worth a visual recap. This is informational only and does **not** block the PR.");
1772
1421
  if (prevPlanId)
1773
1422
  lines.push("", `<!-- plan-id: ${prevPlanId} -->`);
1423
+ if (headMarker)
1424
+ lines.push("", headMarker);
1774
1425
  return lines.join("\n");
1775
1426
  }
1776
1427
  const planUrl = (env.PLAN_URL || "").trim();
@@ -1813,6 +1464,8 @@ export function buildCommentBody(env = process.env) {
1813
1464
  }
1814
1465
  if (markerPlanId)
1815
1466
  lines.push("", `<!-- plan-id: ${markerPlanId} -->`);
1467
+ if (headMarker)
1468
+ lines.push("", headMarker);
1816
1469
  return lines.join("\n");
1817
1470
  }
1818
1471
  // Image URLs are produced by our own recap-image route, but validate each is
@@ -1840,6 +1493,8 @@ export function buildCommentBody(env = process.env) {
1840
1493
  lines.push("> Large diff — this recap is a **summarized** view (top files + schema/API deltas).");
1841
1494
  }
1842
1495
  lines.push("", `<!-- plan-id: ${planId} -->`);
1496
+ if (headMarker)
1497
+ lines.push("", headMarker);
1843
1498
  return lines.join("\n");
1844
1499
  }
1845
1500
  /* -------------------------------------------------------------------------- */
@@ -1898,6 +1553,7 @@ function runBuildPrompt(args) {
1898
1553
  appUrl: optionalArg(args, "app-url") ?? "https://plan.agent-native.com",
1899
1554
  diffPath,
1900
1555
  statPath: optionalArg(args, "stat"),
1556
+ blockReferencePath: optionalArg(args, "block-reference"),
1901
1557
  prevPlanId: optionalArg(args, "prev-plan-id"),
1902
1558
  huge: args.huge === true || args.huge === "true",
1903
1559
  localFiles: args["local-files"] === true || args["local-files"] === "true",
@@ -1910,6 +1566,278 @@ function runBuildPrompt(args) {
1910
1566
  fs.writeFileSync(path.resolve(out), prompt);
1911
1567
  process.stdout.write(`${JSON.stringify({ ok: true, out, skillSource: skill.source, bytes: prompt.length })}\n`);
1912
1568
  }
1569
+ const RECAP_SOURCE_FILENAME = "recap-source.json";
1570
+ const RECAP_URL_REASON_FILENAME = "recap-url-reason.txt";
1571
+ const RECAP_HTTP_TIMEOUT_MS = 45_000;
1572
+ function writeRecapUrlReason(reason, cwd = process.cwd()) {
1573
+ fs.writeFileSync(path.join(cwd, RECAP_URL_REASON_FILENAME), `${sanitizeAgentFailureSummary(reason, 1000)}\n`);
1574
+ }
1575
+ function readRecapUrlReason(cwd = process.cwd()) {
1576
+ return readTextIfExists(path.join(cwd, RECAP_URL_REASON_FILENAME));
1577
+ }
1578
+ function validateRecapSourcePayload(value) {
1579
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1580
+ throw new Error(`${RECAP_SOURCE_FILENAME} must contain a JSON object.`);
1581
+ }
1582
+ const obj = value;
1583
+ if (obj.title !== undefined && typeof obj.title !== "string") {
1584
+ throw new Error(`${RECAP_SOURCE_FILENAME} title must be a string.`);
1585
+ }
1586
+ if (obj.brief !== undefined && typeof obj.brief !== "string") {
1587
+ throw new Error(`${RECAP_SOURCE_FILENAME} brief must be a string.`);
1588
+ }
1589
+ if (!obj.mdx || typeof obj.mdx !== "object" || Array.isArray(obj.mdx)) {
1590
+ throw new Error(`${RECAP_SOURCE_FILENAME} must include an mdx object.`);
1591
+ }
1592
+ const mdx = obj.mdx;
1593
+ if (typeof mdx["plan.mdx"] !== "string" || !mdx["plan.mdx"].trim()) {
1594
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["plan.mdx"] must be a non-empty string.`);
1595
+ }
1596
+ for (const key of ["canvas.mdx", "prototype.mdx", ".plan-state.json"]) {
1597
+ if (mdx[key] !== undefined && typeof mdx[key] !== "string") {
1598
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["${key}"] must be a string when present.`);
1599
+ }
1600
+ }
1601
+ const assets = mdx["assets/"];
1602
+ if (assets !== undefined) {
1603
+ if (!assets || typeof assets !== "object" || Array.isArray(assets)) {
1604
+ throw new Error(`${RECAP_SOURCE_FILENAME} mdx["assets/"] must be an object when present.`);
1605
+ }
1606
+ for (const [name, body] of Object.entries(assets)) {
1607
+ if (typeof body !== "string") {
1608
+ throw new Error(`${RECAP_SOURCE_FILENAME} asset ${JSON.stringify(name)} must be a string.`);
1609
+ }
1610
+ }
1611
+ }
1612
+ return {
1613
+ ...(typeof obj.title === "string" ? { title: obj.title } : {}),
1614
+ ...(typeof obj.brief === "string" ? { brief: obj.brief } : {}),
1615
+ mdx,
1616
+ };
1617
+ }
1618
+ export function readRecapSourcePayload(filePath = RECAP_SOURCE_FILENAME) {
1619
+ const abs = path.resolve(filePath);
1620
+ let text;
1621
+ try {
1622
+ text = fs.readFileSync(abs, "utf8");
1623
+ }
1624
+ catch (err) {
1625
+ throw new Error(`${RECAP_SOURCE_FILENAME} was not created by the agent (${String(err)}).`);
1626
+ }
1627
+ let parsed;
1628
+ try {
1629
+ parsed = JSON.parse(text);
1630
+ }
1631
+ catch (err) {
1632
+ throw new Error(`${RECAP_SOURCE_FILENAME} was not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
1633
+ }
1634
+ return validateRecapSourcePayload(parsed);
1635
+ }
1636
+ function recapActionEndpoint(appUrl, action) {
1637
+ return `${appUrl.replace(/\/$/, "")}/_agent-native/actions/${action}`;
1638
+ }
1639
+ async function fetchJsonWithTimeout(url, init, fetchFn) {
1640
+ return await fetchFn(url, {
1641
+ ...init,
1642
+ signal: init.signal ?? AbortSignal.timeout(RECAP_HTTP_TIMEOUT_MS),
1643
+ });
1644
+ }
1645
+ export async function fetchRecapBlockReference(input) {
1646
+ const fetchFn = input.fetchFn ?? fetch;
1647
+ const out = input.out ?? "recap-blocks.md";
1648
+ const endpoint = new URL(recapActionEndpoint(input.appUrl, "get-plan-blocks"));
1649
+ endpoint.searchParams.set("format", "reference");
1650
+ const response = await fetchJsonWithTimeout(endpoint.toString(), { method: "GET", headers: { accept: "application/json" } }, fetchFn);
1651
+ if (!response.ok) {
1652
+ const detail = await response.text().catch(() => "");
1653
+ throw new Error(`get-plan-blocks failed ${response.status} ${response.statusText}: ${sanitizeAgentFailureSummary(detail, 500)}`);
1654
+ }
1655
+ const json = (await response.json().catch(() => null));
1656
+ if (!json?.reference) {
1657
+ throw new Error("get-plan-blocks returned no reference text.");
1658
+ }
1659
+ fs.writeFileSync(path.resolve(out), json.reference);
1660
+ return { ok: true, out, count: json.count };
1661
+ }
1662
+ function recapUrlFromPublishResult(result, appUrl) {
1663
+ const candidates = [];
1664
+ const ids = [];
1665
+ const visit = (value, depth = 0) => {
1666
+ if (!value || typeof value !== "object" || depth > 3)
1667
+ return;
1668
+ const obj = value;
1669
+ for (const key of ["webUrl", "url", "path", "href"]) {
1670
+ const candidate = obj[key];
1671
+ if (typeof candidate === "string")
1672
+ candidates.push(candidate);
1673
+ }
1674
+ for (const key of ["planId", "id"]) {
1675
+ const candidate = obj[key];
1676
+ if (typeof candidate === "string" &&
1677
+ /^[A-Za-z0-9_-]{1,80}$/.test(candidate)) {
1678
+ ids.push(candidate);
1679
+ }
1680
+ }
1681
+ for (const key of ["plan", "openLink", "link", "result"]) {
1682
+ visit(obj[key], depth + 1);
1683
+ }
1684
+ };
1685
+ visit(result);
1686
+ for (const candidate of candidates) {
1687
+ const canonical = canonicalRecapUrl(candidate, appUrl);
1688
+ if (canonical)
1689
+ return canonical;
1690
+ }
1691
+ for (const id of ids) {
1692
+ const canonical = canonicalRecapUrl(`/recaps/${id}`, appUrl);
1693
+ if (canonical)
1694
+ return canonical;
1695
+ }
1696
+ return "";
1697
+ }
1698
+ function shouldRetryRecapPublish(status) {
1699
+ return (status === 408 ||
1700
+ status === 409 ||
1701
+ status === 425 ||
1702
+ status === 429 ||
1703
+ status >= 500);
1704
+ }
1705
+ export async function publishRecapSource(input) {
1706
+ const cwd = input.cwd ?? process.cwd();
1707
+ const sourcePath = input.sourcePath ?? path.join(cwd, RECAP_SOURCE_FILENAME);
1708
+ const out = input.out ?? path.join(cwd, "recap-url.txt");
1709
+ const token = input.token.trim();
1710
+ if (!token)
1711
+ throw new Error("PLAN_RECAP_TOKEN is empty.");
1712
+ const source = readRecapSourcePayload(sourcePath);
1713
+ const sourceUrl = input.sourceUrl ??
1714
+ (input.repo && input.pr
1715
+ ? `https://github.com/${input.repo}/pull/${input.pr}`
1716
+ : undefined);
1717
+ const body = {
1718
+ ...(input.prevPlanId ? { planId: input.prevPlanId } : {}),
1719
+ ...(source.title ? { title: source.title } : {}),
1720
+ ...(source.brief ? { brief: source.brief } : {}),
1721
+ visibility: "org",
1722
+ source: "imported",
1723
+ ...(input.repo ? { repoPath: input.repo } : {}),
1724
+ ...(sourceUrl ? { sourceUrl } : {}),
1725
+ currentFocus: "visual recap review",
1726
+ status: "review",
1727
+ mdx: source.mdx,
1728
+ };
1729
+ const endpoint = recapActionEndpoint(input.appUrl, "create-visual-recap");
1730
+ const fetchFn = input.fetchFn ?? fetch;
1731
+ let lastError = "";
1732
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
1733
+ try {
1734
+ const response = await fetchJsonWithTimeout(endpoint, {
1735
+ method: "POST",
1736
+ headers: {
1737
+ accept: "application/json",
1738
+ "content-type": "application/json",
1739
+ authorization: `Bearer ${token}`,
1740
+ },
1741
+ body: JSON.stringify(body),
1742
+ }, fetchFn);
1743
+ const text = await response.text().catch((err) => String(err));
1744
+ if (!response.ok) {
1745
+ lastError = `create-visual-recap failed ${response.status} ${response.statusText}: ${sanitizeAgentFailureSummary(text, 800)}`;
1746
+ if (attempt < 3 && shouldRetryRecapPublish(response.status)) {
1747
+ await delay(attempt * 2000);
1748
+ continue;
1749
+ }
1750
+ throw new Error(lastError);
1751
+ }
1752
+ let result = null;
1753
+ try {
1754
+ result = text ? JSON.parse(text) : null;
1755
+ }
1756
+ catch {
1757
+ throw new Error("create-visual-recap returned non-JSON output.");
1758
+ }
1759
+ const url = recapUrlFromPublishResult(result, input.appUrl);
1760
+ if (!url) {
1761
+ throw new Error("create-visual-recap succeeded but did not return a usable /recaps/<id> URL or plan id.");
1762
+ }
1763
+ fs.writeFileSync(path.resolve(out), `${url}\n`);
1764
+ try {
1765
+ fs.rmSync(path.join(cwd, RECAP_URL_REASON_FILENAME), { force: true });
1766
+ }
1767
+ catch {
1768
+ /* ignore */
1769
+ }
1770
+ return { ok: true, url, out };
1771
+ }
1772
+ catch (err) {
1773
+ lastError = err instanceof Error ? err.message : String(err);
1774
+ if (attempt < 3 &&
1775
+ /fetch failed|network|timeout|timed out|ECONNRESET|ETIMEDOUT/i.test(lastError)) {
1776
+ await delay(attempt * 2000);
1777
+ continue;
1778
+ }
1779
+ throw new Error(lastError);
1780
+ }
1781
+ }
1782
+ throw new Error(lastError || "create-visual-recap failed.");
1783
+ }
1784
+ async function runBlockReference(args) {
1785
+ const appUrl = optionalArg(args, "app-url") ??
1786
+ process.env.PLAN_RECAP_APP_URL ??
1787
+ DEFAULT_RECAP_APP_URL;
1788
+ const out = optionalArg(args, "out") ?? "recap-blocks.md";
1789
+ try {
1790
+ const result = await fetchRecapBlockReference({ appUrl, out });
1791
+ writeGitHubOutput("ok", "true");
1792
+ writeGitHubOutput("out", result.out);
1793
+ writeGitHubOutput("reason", "");
1794
+ process.stdout.write(`${JSON.stringify(result)}\n`);
1795
+ }
1796
+ catch (err) {
1797
+ const reason = sanitizeAgentFailureSummary(err instanceof Error ? err.message : String(err), 1000);
1798
+ writeRecapUrlReason(reason);
1799
+ writeGitHubOutput("ok", "false");
1800
+ writeGitHubOutput("out", "");
1801
+ writeGitHubOutput("reason", reason);
1802
+ process.stdout.write(`${JSON.stringify({ ok: false, reason })}\n`);
1803
+ process.exitCode = 1;
1804
+ }
1805
+ }
1806
+ async function runPublish(args) {
1807
+ const appUrl = optionalArg(args, "app-url") ??
1808
+ process.env.PLAN_RECAP_APP_URL ??
1809
+ DEFAULT_RECAP_APP_URL;
1810
+ const token = optionalArg(args, "token") ?? process.env.PLAN_RECAP_TOKEN ?? "";
1811
+ const out = optionalArg(args, "out") ?? "recap-url.txt";
1812
+ const done = (obj) => {
1813
+ process.stdout.write(`${JSON.stringify(obj)}\n`);
1814
+ };
1815
+ try {
1816
+ const result = await publishRecapSource({
1817
+ appUrl,
1818
+ token,
1819
+ sourcePath: optionalArg(args, "source") ?? RECAP_SOURCE_FILENAME,
1820
+ out,
1821
+ prevPlanId: optionalArg(args, "prev-plan-id"),
1822
+ repo: optionalArg(args, "repo") ?? process.env.GITHUB_REPOSITORY,
1823
+ pr: optionalArg(args, "pr") ?? process.env.PR_NUMBER,
1824
+ sourceUrl: optionalArg(args, "source-url"),
1825
+ });
1826
+ writeGitHubOutput("ok", "true");
1827
+ writeGitHubOutput("plan_url", result.url);
1828
+ writeGitHubOutput("reason", "");
1829
+ done(result);
1830
+ }
1831
+ catch (err) {
1832
+ const reason = sanitizeAgentFailureSummary(err instanceof Error ? err.message : String(err), 1000);
1833
+ writeRecapUrlReason(reason);
1834
+ writeGitHubOutput("ok", "false");
1835
+ writeGitHubOutput("plan_url", "");
1836
+ writeGitHubOutput("reason", reason);
1837
+ done({ ok: false, reason });
1838
+ process.exitCode = 1;
1839
+ }
1840
+ }
1913
1841
  function delay(ms) {
1914
1842
  return ms > 0
1915
1843
  ? new Promise((resolve) => setTimeout(resolve, ms))
@@ -2012,6 +1940,47 @@ async function defaultImportPlaywright() {
2012
1940
  return (await import("@playwright/test"));
2013
1941
  }
2014
1942
  }
1943
+ const RECAP_SYSTEM_CHROME_EXECUTABLES = [
1944
+ "/usr/bin/google-chrome-stable",
1945
+ "/usr/bin/google-chrome",
1946
+ "/usr/bin/chromium-browser",
1947
+ "/usr/bin/chromium",
1948
+ ];
1949
+ function shouldTrySystemChromeFallback(err) {
1950
+ const message = err instanceof Error ? err.message : String(err);
1951
+ return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(message);
1952
+ }
1953
+ export async function launchRecapChromium(chromium) {
1954
+ const launchOptions = { args: ["--no-sandbox"] };
1955
+ try {
1956
+ return await chromium.launch(launchOptions);
1957
+ }
1958
+ catch (err) {
1959
+ if (!shouldTrySystemChromeFallback(err))
1960
+ throw err;
1961
+ const fallbackErrors = [];
1962
+ for (const executablePath of RECAP_SYSTEM_CHROME_EXECUTABLES) {
1963
+ if (!fs.existsSync(executablePath))
1964
+ continue;
1965
+ try {
1966
+ process.stderr.write(`[recap shot] Playwright browser unavailable; trying system Chrome at ${executablePath}\n`);
1967
+ return await chromium.launch({ ...launchOptions, executablePath });
1968
+ }
1969
+ catch (fallbackErr) {
1970
+ const message = fallbackErr instanceof Error
1971
+ ? fallbackErr.message
1972
+ : String(fallbackErr);
1973
+ fallbackErrors.push(`${executablePath}: ${message}`);
1974
+ process.stderr.write(`[recap shot] system Chrome launch failed at ${executablePath}: ${message}\n`);
1975
+ }
1976
+ }
1977
+ if (fallbackErrors.length) {
1978
+ const originalMessage = err instanceof Error ? err.message : String(err);
1979
+ throw new Error(`${originalMessage}; system Chrome fallback failed (${fallbackErrors.join("; ")})`, { cause: err });
1980
+ }
1981
+ throw err;
1982
+ }
1983
+ }
2015
1984
  function parseRecapScreenshotTheme(value) {
2016
1985
  if (value === undefined)
2017
1986
  return undefined;
@@ -2080,13 +2049,14 @@ importPlaywright = defaultImportPlaywright) {
2080
2049
  return;
2081
2050
  }
2082
2051
  let captured = false;
2052
+ let reason = "";
2083
2053
  let browser;
2084
2054
  const hardTimer = setTimeout(() => {
2085
2055
  done({ ok: false, reason: "hard 60s timeout reached" });
2086
2056
  process.exit(0);
2087
2057
  }, 60_000);
2088
2058
  try {
2089
- browser = await chromium.launch({ args: ["--no-sandbox"] });
2059
+ browser = await launchRecapChromium(chromium);
2090
2060
  const context = await browser.newContext({
2091
2061
  viewport: RECAP_SHOT_VIEWPORT,
2092
2062
  deviceScaleFactor: RECAP_SHOT_DEVICE_SCALE_FACTOR,
@@ -2211,12 +2181,20 @@ importPlaywright = defaultImportPlaywright) {
2211
2181
  });
2212
2182
  await page.waitForTimeout(250);
2213
2183
  await page.screenshot({ path: out });
2214
- // If the captured PNG is over the upload cap, remove it so the upload step
2215
- // sees no file and the comment falls back to a link-only recap.
2184
+ // If the captured PNG is over the upload cap, retry at CSS-pixel scale
2185
+ // before giving up. The server route rejects oversized files, and the
2186
+ // GitHub comment can only embed an image after a successful upload.
2216
2187
  const firstSize = fs.existsSync(out) ? fs.statSync(out).size : 0;
2217
2188
  if (firstSize > RECAP_SHOT_MAX_BYTES) {
2218
- process.stderr.write(`[recap shot] PNG is ${firstSize} bytes (cap ${RECAP_SHOT_MAX_BYTES}) — skipping upload\n`);
2189
+ process.stderr.write(`[recap shot] PNG is ${firstSize} bytes (cap ${RECAP_SHOT_MAX_BYTES}) — retrying at CSS-pixel scale\n`);
2219
2190
  fs.unlinkSync(out);
2191
+ await page.screenshot({ path: out, scale: "css" });
2192
+ const retrySize = fs.existsSync(out) ? fs.statSync(out).size : 0;
2193
+ if (retrySize > RECAP_SHOT_MAX_BYTES) {
2194
+ reason = `screenshot PNG exceeded upload cap (${retrySize} bytes > ${RECAP_SHOT_MAX_BYTES})`;
2195
+ process.stderr.write(`[recap shot] ${reason}; skipping upload\n`);
2196
+ fs.unlinkSync(out);
2197
+ }
2220
2198
  }
2221
2199
  captured = fs.existsSync(out);
2222
2200
  await browser.close();
@@ -2240,8 +2218,12 @@ importPlaywright = defaultImportPlaywright) {
2240
2218
  let imageUrl = null;
2241
2219
  if (captured && token && appUrl) {
2242
2220
  imageUrl = await uploadRecapImage({ appUrl, token, pngPath: out });
2221
+ if (!imageUrl) {
2222
+ reason = "screenshot captured but image upload failed";
2223
+ }
2243
2224
  }
2244
- done({ ok: captured, out, imageUrl });
2225
+ const ok = captured && (!(token && appUrl) || !!imageUrl);
2226
+ done({ ok, out, imageUrl, ...(reason ? { reason } : {}) });
2245
2227
  }
2246
2228
  async function runComment(args, sub) {
2247
2229
  const token = stringArg(args, "token");
@@ -2259,6 +2241,24 @@ async function runComment(args, sub) {
2259
2241
  return;
2260
2242
  }
2261
2243
  if (sub === "upsert") {
2244
+ const headSha = optionalArg(args, "head-sha") ?? process.env.HEAD_SHA ?? "";
2245
+ if (headSha) {
2246
+ const current = await isPullRequestHeadCurrent({
2247
+ token,
2248
+ owner,
2249
+ repo,
2250
+ issue,
2251
+ headSha,
2252
+ });
2253
+ if (current === false) {
2254
+ process.stdout.write(`${JSON.stringify({
2255
+ action: "skipped",
2256
+ id: 0,
2257
+ reason: "stale head sha",
2258
+ })}\n`);
2259
+ return;
2260
+ }
2261
+ }
2262
2262
  const result = await upsertComment({
2263
2263
  token,
2264
2264
  owner,
@@ -2617,13 +2617,16 @@ export function canonicalRecapUrl(rawUrl, appUrl) {
2617
2617
  }
2618
2618
  }
2619
2619
  export function inferLocalRecapUrlFailureReason(input = {}) {
2620
- const recapUrlPath = path.join(input.cwd ?? process.cwd(), "recap-url.txt");
2620
+ const cwd = input.cwd ?? process.cwd();
2621
+ const explicitReason = readRecapUrlReason(cwd);
2622
+ const recapUrlPath = path.join(cwd, "recap-url.txt");
2621
2623
  const raw = readTextIfExists(recapUrlPath);
2622
- if (raw === null)
2623
- return "recap-url.txt was not created by the agent.";
2624
+ if (raw === null) {
2625
+ return explicitReason?.trim() || "recap-url.txt was not created.";
2626
+ }
2624
2627
  const value = raw.replace(/[\r\n\s]/g, "");
2625
2628
  if (!value)
2626
- return "recap-url.txt was empty.";
2629
+ return explicitReason?.trim() || "recap-url.txt was empty.";
2627
2630
  const appUrl = input.appUrl ||
2628
2631
  process.env.PLAN_RECAP_APP_URL ||
2629
2632
  "https://plan.agent-native.com";
@@ -2637,10 +2640,12 @@ export function inferLocalRecapUrlFailureReason(input = {}) {
2637
2640
  if (parsed.origin !== trusted.origin) {
2638
2641
  return `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}.`;
2639
2642
  }
2640
- return "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app.";
2643
+ return (explicitReason?.trim() ||
2644
+ "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app.");
2641
2645
  }
2642
2646
  catch {
2643
- return "recap-url.txt was not a valid URL or recap path.";
2647
+ return (explicitReason?.trim() ||
2648
+ "recap-url.txt was not a valid URL or recap path.");
2644
2649
  }
2645
2650
  }
2646
2651
  export function buildRecapFailureDiagnostic(input) {
@@ -3063,10 +3068,10 @@ Usage:
3063
3068
  npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex] [--app-url <url>] [--skip-secrets] [--dry-run] [--force]
3064
3069
  npx @agent-native/core@latest recap doctor [--repo owner/name] [--agent claude|codex] [--app-url <url>]
3065
3070
  npx @agent-native/core@latest recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
3066
- npx @agent-native/core@latest recap mcp-config --agent claude|codex --app-url <url> [--out <path>]
3067
- npx @agent-native/core@latest recap mcp-smoke [--app-url <url>] [--token <planToken>]
3071
+ npx @agent-native/core@latest recap block-reference [--app-url <url>] [--out recap-blocks.md]
3068
3072
  npx @agent-native/core@latest recap scan --diff <path> [--mode off|high-confidence|strict]
3069
- 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>]
3073
+ npx @agent-native/core@latest recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--block-reference recap-blocks.md] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
3074
+ npx @agent-native/core@latest recap publish [--source recap-source.json] [--out recap-url.txt] [--repo owner/name] [--pr <n>] [--prev-plan-id <id>] [--app-url <url>] [--token <planToken>]
3070
3075
  npx @agent-native/core@latest recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png] [--theme light|dark]
3071
3076
  npx @agent-native/core@latest recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
3072
3077
  npx @agent-native/core@latest recap agent-summary --result-file <path> [--stderr-file <path>] [--exit-code-file <path>] [--agent claude|codex]
@@ -3103,11 +3108,12 @@ Usage:
3103
3108
  shapes such as private key blocks and known provider token prefixes. Set
3104
3109
  VISUAL_RECAP_SECRET_SCAN=strict, or pass --mode strict, to restore generic
3105
3110
  TOKEN/SECRET assignment suppression; set off to disable this preflight.
3106
- npx @agent-native/core@latest recap mcp-smoke
3107
- Non-mutating Plan MCP JSON-RPC smoke test. Calls initialize + tools/list
3108
- against PLAN_RECAP_APP_URL / PLAN_RECAP_TOKEN and requires get-plan-blocks,
3109
- create-visual-recap, and set-resource-visibility to be exposed. Writes
3110
- ok=<true|false> and summary=<diagnostic> to $GITHUB_OUTPUT.
3111
+ npx @agent-native/core@latest recap block-reference
3112
+ Fetch the target Plan app's live get-plan-blocks reference over the public
3113
+ action route and write it to recap-blocks.md for the CI agent to read.
3114
+ npx @agent-native/core@latest recap publish
3115
+ Validate recap-source.json from the CI agent, publish it by POSTing the
3116
+ authenticated create-visual-recap action, and write recap-url.txt.
3111
3117
  npx @agent-native/core@latest recap setup
3112
3118
  Write/refresh .github/workflows/pr-visual-recap.yml, then configure GitHub
3113
3119
  Actions secrets and variables with gh when values are available from env or
@@ -3130,11 +3136,8 @@ export async function runRecap(argv) {
3130
3136
  case "collect-diff":
3131
3137
  runCollectDiff(args);
3132
3138
  return;
3133
- case "mcp-config":
3134
- runMcpConfig(args);
3135
- return;
3136
- case "mcp-smoke":
3137
- await runMcpSmoke(args);
3139
+ case "block-reference":
3140
+ await runBlockReference(args);
3138
3141
  return;
3139
3142
  case "scan":
3140
3143
  runScan(args);
@@ -3142,6 +3145,9 @@ export async function runRecap(argv) {
3142
3145
  case "build-prompt":
3143
3146
  runBuildPrompt(args);
3144
3147
  return;
3148
+ case "publish":
3149
+ await runPublish(args);
3150
+ return;
3145
3151
  case "shot":
3146
3152
  await runShot(args);
3147
3153
  return;