@agent-native/core 0.114.7 → 0.114.9

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 (28) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +17 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +3 -2
  5. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  6. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  7. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  8. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  9. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  10. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  11. package/corpus/toolkit/CHANGELOG.md +6 -0
  12. package/corpus/toolkit/package.json +1 -1
  13. package/dist/cli/pr-visual-recap-workflow.d.ts +2 -2
  14. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  15. package/dist/cli/pr-visual-recap-workflow.js +2 -2
  16. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  17. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  18. package/dist/observability/routes.d.ts +5 -5
  19. package/dist/resources/handlers.d.ts +1 -1
  20. package/dist/secrets/routes.d.ts +9 -9
  21. package/dist/server/agent-chat-plugin.d.ts +5 -0
  22. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  23. package/dist/server/agent-chat-plugin.js +10 -9
  24. package/dist/server/agent-chat-plugin.js.map +1 -1
  25. package/dist/server/transcribe-voice.d.ts +1 -1
  26. package/package.json +3 -3
  27. package/src/cli/pr-visual-recap-workflow.ts +3 -2
  28. package/src/server/agent-chat-plugin.ts +16 -9
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1518
32
32
  - toolkit files: 137
33
- - template files: 6161
33
+ - template files: 6163
@@ -1,5 +1,22 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.114.9
4
+
5
+ ### Patch Changes
6
+
7
+ - fb32d85: Run configurable PR Visual Recap jobs with Bash on every runner platform.
8
+ - Updated dependencies [fb32d85]
9
+ - @agent-native/recap-cli@0.4.6
10
+
11
+ ## 0.114.8
12
+
13
+ ### Patch Changes
14
+
15
+ - dcd0810: Forward app-specific agent run no-progress timeouts through every interactive chat handler.
16
+ - dcd0810: Add clear creation actions to empty resource views and improve collaboration usage feedback.
17
+ - Updated dependencies [dcd0810]
18
+ - @agent-native/toolkit@0.8.2
19
+
3
20
  ## 0.114.7
4
21
 
5
22
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.114.7",
3
+ "version": "0.114.9",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -1,2 +1,3 @@
1
- /** Compatibility re-export for the workflow bundled by the recap CLI package. */
2
- export { PR_VISUAL_RECAP_WORKFLOW_YML } from "@agent-native/recap-cli";
1
+ /** Canonical PR Visual Recap workflow bundled by the CLI installer. */
2
+ export const PR_VISUAL_RECAP_WORKFLOW_YML =
3
+ 'name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo\'s visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || \'claude\' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON(\'["OWNER","MEMBER","COLLABORATOR"]\'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || \'ubuntu-latest\') || \'ubuntu-latest\' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals — never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != \'\' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != \'\' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != \'\' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != \'\' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push(\'no pull_request payload\');\n if (pr && pr.draft) reasons.push(\'draft PR\');\n if (pr && context.payload.action === \'closed\' && !pr.merged) {\n reasons.push(\'closed without merge\');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub\'s "Send secrets to workflows from pull requests" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip — with an actionable hint — those that don\'t.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || \'\').toUpperCase();\n const trustedAssociations = [\'OWNER\', \'MEMBER\', \'COLLABORATOR\'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = \'ubuntu-latest\';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || \'"ubuntu-latest"\');\n const hosted = typeof candidate === \'string\' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes(\'self-hosted\') && candidate.every((label) => typeof label === \'string\' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error(\'unsupported runner value\');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push(\'invalid VISUAL_RECAP_RUNS_ON JSON\');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push(\'self-hosted runner mode requires a trusted same-repository PR author\');\n }\n if (isFork && process.env.HAS_PLAN !== \'true\') {\n reasons.push(`fork PR (${headRepo}) without secret access — enable "Send secrets to workflows from pull requests" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || \'\').toLowerCase();\n const botAuthors = [\'dependabot[bot]\', \'dependabot\', \'renovate[bot]\', \'renovate\'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === \'Bot\') reasons.push(\'bot author (type=Bot)\');\n\n if (!isFork && process.env.HAS_PLAN !== \'true\') reasons.push(\'PLAN_RECAP_TOKEN not configured\');\n\n // Normalize + validate the agent so a mis-cased value can\'t pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || \'claude\').toLowerCase();\n const agent = [\'deepseek\', \'kimi\', \'moonshot\', \'custom\'].includes(rawAgent) ? \'openai-compatible\' : rawAgent;\n if (![\'claude\', \'codex\', \'openai-compatible\'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT "${process.env.AGENT}" (expected "claude", "codex", or "openai-compatible")`);\n } else if (agent === \'codex\') {\n if (process.env.HAS_OPENAI !== \'true\') reasons.push(\'OPENAI_API_KEY not configured (codex backend)\');\n } else if (agent === \'claude\') {\n if (process.env.HAS_ANTHROPIC !== \'true\') reasons.push(\'ANTHROPIC_API_KEY not configured (claude backend)\');\n } else {\n if (process.env.HAS_COMPATIBLE !== \'true\') reasons.push(\'VISUAL_RECAP_API_KEY not configured (openai-compatible backend)\');\n if (!(process.env.VISUAL_RECAP_MODEL || \'\').trim()) reasons.push(\'VISUAL_RECAP_MODEL is required (openai-compatible backend)\');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || \'\';\n try {\n const parsed = new URL(baseUrl);\n if (![\'http:\', \'https:\'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials\');\n }\n } catch {\n reasons.push(\'VISUAL_RECAP_BASE_URL must be a valid http(s) URL\');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || \'\';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || \'auto\').toLowerCase();\n if (![\'auto\', \'latest\', \'repo\'].includes(skillSource)) {\n reasons.push(\'invalid VISUAL_RECAP_SKILL_SOURCE value (expected "auto", "latest", or "repo")\');\n }\n const usesRepoSkill = skillSource === \'repo\';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can\'t rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith(\'.claude/\') ||\n p === \'CLAUDE.md\' ||\n p === \'AGENTS.md\' ||\n p === \'.mcp.json\';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(\', \')}${hits.length > 3 ? \', …\' : \'\'}) — skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can\'t be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput(\'run\', run ? \'true\' : \'false\');\n core.setOutput(\'agent\', agent);\n core.setOutput(\'runs_on\', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it\'s clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join(\'; \')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = \'<!-- pr-visual-recap -->\';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === \'Bot\' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || \'\').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : \'latest push\';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith(\'could not list PR files for the self-modifying guard\')\n )[0] || reasons[0] || \'skipped\';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap — skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : \'\').match(/<!--\\s*plan-id:\\s*([A-Za-z0-9_-]{1,64})\\s*-->/);\n const planIdMarker = planIdMatch ? `\\n\\n<!-- plan-id: ${planIdMatch[1]} -->` : \'\';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == \'true\'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || \'https://plan.agent-native.com\' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || \'\' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || \'auto\' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || \'high-confidence\' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don\'t leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. "1.2.3"). Defaults to\n # "latest" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n if [ "$GITHUB_REPOSITORY" = "BuilderIO/agent-native" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo "local=true" >> "$GITHUB_OUTPUT"\n else\n echo "local=false" >> "$GITHUB_OUTPUT"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == \'true\'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == \'true\'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: "22"\n cache: ${{ steps.cli.outputs.local == \'true\' && \'pnpm\' || \'\' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == \'true\'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo "RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != \'true\'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n VERSION="$RECAP_CLI_VERSION"\n if [ "$VERSION" = "latest" ]; then\n VERSION="$(npm view @agent-native/recap-cli@latest version)"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix "$RUNNER_TEMP/recap-cli" --no-audit --no-fund --ignore-scripts "@agent-native/recap-cli@$VERSION"; then\n break\n fi\n if [ "$attempt" = "3" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo "RECAP_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"\n echo "RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n echo "RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright" >> "$GITHUB_ENV"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.cli.outputs.local != \'true\'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || \'latest\' }}\n run: |\n set -euo pipefail\n CORE_VERSION="$CORE_CLI_VERSION"\n if [ "$CORE_VERSION" = "latest" ]; then\n CORE_VERSION="$(npm view @agent-native/core@latest version)"\n fi\n npm install --prefix "$RUNNER_TEMP/recap-code" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai "@agent-native/core@$CORE_VERSION"\n echo "CORE_CLI_VERSION=$CORE_VERSION" >> "$GITHUB_ENV"\n echo "CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native" >> "$GITHUB_ENV"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha "$HEAD_SHA" --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e "${HEAD_SHA}^{commit}" 2>/dev/null; then\n git update-ref refs/recap/pr-head "$HEAD_SHA"\n else\n AUTH_B64="$(printf \'x-access-token:%s\' "$GH_TOKEN" | base64 | tr -d \'\\n\')"\n git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64" fetch origin "pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head"\n fi\n FETCHED_SHA="$(git rev-parse refs/recap/pr-head)"\n if [ "$FETCHED_SHA" != "$HEAD_SHA" ]; then\n echo "FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA — aborting to avoid recapping the wrong commit"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base "$BASE_SHA" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app\'s action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e \'\n const https = require("https");\n const url = new URL("/_agent-native/actions/record-recap-usage", process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const req = https.request(url, { method: "POST", headers: { "authorization": "Bearer " + process.env.PLAN_RECAP_TOKEN, "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.end(JSON.stringify({ planId: "__probe__" }));\n \' 2>/dev/null || echo "0")\n if [ "$HTTP_STATUS" = "401" ]; then\n echo "auth_failed=true" >> "$GITHUB_OUTPUT"\n else\n echo "auth_failed=false" >> "$GITHUB_OUTPUT"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app\'s recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy — the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE="$1" METHOD="$2" node -e \'\n const https = require("https");\n const base = process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === "GET") url.searchParams.set("format", "reference");\n const req = https.request(url, { method: process.env.METHOD, headers: { "authorization": "Bearer " + (process.env.PLAN_RECAP_TOKEN || ""), "content-type": "application/json" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on("error", () => process.stdout.write("0"));\n req.on("timeout", () => { process.stdout.write("0"); req.destroy(); });\n if (process.env.METHOD === "POST") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n \' 2>/dev/null || echo "0"\n }\n CREATE_STATUS="$(probe_status /_agent-native/actions/create-visual-recap POST)"\n BLOCKS_STATUS="$(probe_status /_agent-native/actions/get-plan-blocks GET)"\n REASON=""\n if [ "$CREATE_STATUS" = "404" ] || [ "$BLOCKS_STATUS" = "404" ]; then\n REASON="Plan app routes return 404 — deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating."\n echo "::error::$REASON"\n echo "unhealthy=true" >> "$GITHUB_OUTPUT"\n else\n echo "unhealthy=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_ROUTE_HEALTH_EOF__\'\n echo "$REASON"\n echo \'__RECAP_ROUTE_HEALTH_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON="$($RECAP_CLI recap scan --diff recap.diff --mode "$VISUAL_RECAP_SECRET_SCAN")"; then\n SCAN_JSON=\'{"suppressed":true,"reason":"secret scan failed to run; failing closed"}\'\n fi\n {\n echo \'json<<__RECAP_SCAN_EOF__\'\n echo "$SCAN_JSON"\n echo \'__RECAP_SCAN_EOF__\'\n } >> "$GITHUB_OUTPUT"\n SUPPRESSED=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?"true":"false")}catch{process.stdout.write("true")}\' "$SCAN_JSON")\n echo "suppressed=$SUPPRESSED" >> "$GITHUB_OUTPUT"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID="$($RECAP_CLI recap comment find-plan-id --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN")"\n echo "plan_id=$PLAN_ID" >> "$GITHUB_OUTPUT"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url "$PLAN_RECAP_APP_URL" --out recap-blocks.md; then\n echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "ok=false" >> "$GITHUB_OUTPUT"\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_EOF__\'\n echo "Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX."\n echo \'__RECAP_BLOCK_REFERENCE_EOF__\'\n } >> "$GITHUB_OUTPUT"\n cat > recap-blocks.md <<\'EOF\'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --head "$HEAD_SHA" --app-url "$PLAN_RECAP_APP_URL" --skill-source "$VISUAL_RECAP_SKILL_SOURCE" --out recap-prompt.md)\n if [ "${DIFF_HUGE:-}" = "true" ]; then ARGS+=(--huge); fi\n if [ "${IS_FORK:-}" = "true" ]; then ARGS+=(--fork-pr true); fi\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n $RECAP_CLI recap build-prompt "${ARGS[@]}"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == \'claude\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write,Bash(git diff:*)"\n CLAUDE_ARGS=(-p "$(cat recap-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS="$?"\n set -e\n echo "$CLAUDE_STATUS" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # "retry me" signal — the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' claude-result.json claude-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == \'codex\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex\'s own sandbox\n # (bubblewrap can\'t init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-prompt.md)" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_STATUS" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) — the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == \'openai-compatible\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-prompt.md)" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_STATUS" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- \'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden\' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo "::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry."\n else\n echo "::warning::recap-source.json missing after the agent run; retrying the agent once."\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Build one-shot recap repair prompt\n id: repair_prompt\n if: steps.publish.outputs.repairable == \'true\'\n run: |\n set -euo pipefail\n cp recap-source.json recap-source.initial.json\n $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md\n\n - name: Repair recap source (Claude Code)\n id: claude_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'claude\'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS="Read,Write"\n CLAUDE_ARGS=(-p "$(cat recap-repair-prompt.md)" --allowedTools "$CLAUDE_ALLOWED_TOOLS" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model "${VISUAL_RECAP_MODEL:-claude-sonnet-5}")\n rm -f claude-repair-result.json claude-repair-stderr.log\n set +e\n npx -y @anthropic-ai/claude-code@2 "${CLAUDE_ARGS[@]}" > claude-repair-result.json 2> claude-repair-stderr.log\n CLAUDE_REPAIR_STATUS="$?"\n set -e\n echo "$CLAUDE_REPAIR_STATUS" > claude-repair-exit-code.txt\n if [ "$CLAUDE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (Codex)\n id: codex_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'codex\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n "${VISUAL_RECAP_MODEL:-}" ]; then CODEX_ARGS+=(--model "$VISUAL_RECAP_MODEL"); fi\n case "${VISUAL_RECAP_REASONING:-}" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c "model_reasoning_effort=\\"$VISUAL_RECAP_REASONING\\"") ;;\n "") ;;\n *) echo "Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING" ;;\n esac\n rm -f codex-repair-events.jsonl codex-repair-stderr.log\n set +e\n npx -y @openai/codex@0 "${CODEX_ARGS[@]}" --json "$(cat recap-repair-prompt.md)" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl\n CODEX_REPAIR_STATUS="${PIPESTATUS[0]}"\n set -e\n echo "$CODEX_REPAIR_STATUS" > codex-repair-exit-code.txt\n if [ "$CODEX_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Repair recap source (OpenAI-compatible)\n id: openai_compatible_repair\n if: steps.publish.outputs.repairable == \'true\' && needs.gate.outputs.agent == \'openai-compatible\'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log\n set +e\n $CODE_CLI code exec --permission-mode auto-edit "$(cat recap-repair-prompt.md)" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log\n OPENAI_COMPATIBLE_REPAIR_STATUS="$?"\n set -e\n echo "$OPENAI_COMPATIBLE_REPAIR_STATUS" > openai-compatible-repair-exit-code.txt\n if [ "$OPENAI_COMPATIBLE_REPAIR_STATUS" -eq 0 ]; then echo "ok=true" >> "$GITHUB_OUTPUT"; else echo "ok=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Validate repaired recap source\n id: repaired_source\n if: steps.publish.outputs.repairable == \'true\'\n env:\n REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}\n run: |\n set -uo pipefail\n REPAIR_REASON=""\n if [ "${REPAIR_AGENT_OK:-false}" != "true" ]; then\n echo "ok=false" >> "$GITHUB_OUTPUT"\n REPAIR_REASON="Repair agent exited unsuccessfully; repaired source was not published."\n else\n VALIDATION_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)"\n REPAIR_OK="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$VALIDATION_JSON")"\n REPAIR_REASON="$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("Repair validation returned invalid output.")}\' "$VALIDATION_JSON")"\n echo "ok=$REPAIR_OK" >> "$GITHUB_OUTPUT"\n fi\n if [ -n "$REPAIR_REASON" ]; then\n echo "$REPAIR_REASON" > recap-url-reason.txt\n fi\n {\n echo \'reason<<__RECAP_REPAIR_REASON_EOF__\'\n echo "$REPAIR_REASON"\n echo \'__RECAP_REPAIR_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Publish repaired recap source\n id: publish_repair\n if: steps.repaired_source.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN")\n if [ -n "${PREV_PLAN_ID:-}" ]; then ARGS+=(--prev-plan-id "$PREV_PLAN_ID"); fi\n ARGS+=(--source-type pull-request --source-repo "$GITHUB_REPOSITORY" --source-pr-number "$PR_NUMBER")\n if [ "${PR_MERGED:-false}" = "true" ] || [ -n "${PR_MERGED_AT:-}" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n "${PR_STATE:-}" ]; then\n ARGS+=(--source-pr-state "$PR_STATE")\n fi\n if [ -n "${PR_MERGED_AT:-}" ]; then ARGS+=(--source-pr-merged-at "$PR_MERGED_AT"); fi\n $RECAP_CLI recap publish "${ARGS[@]}"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n run: |\n set -uo pipefail\n PLAN_URL=""\n URL_REASON=""\n if [ -f recap-url.txt ]; then\n PLAN_URL="$(tr -d \'\\r\\n\' < recap-url.txt | tr -d \' \')"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON="$(cat recap-url-reason.txt)"\n else\n URL_REASON="recap-url.txt was not created."\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z "$URL_REASON" ]; then\n URL_RESULT=$(PLAN_URL="$PLAN_URL" node <<\'NODE\'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || "";\n if (!raw) {\n emit({ url: "", reason: "recap-url.txt was empty" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || "https://plan.agent-native.com");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: "", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, "");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || "/");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: "" });\n process.exit(0);\n }\n }\n emit({ url: "", reason: "recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app" });\n } catch {\n emit({ url: "", reason: "recap-url.txt was not a valid URL or recap path" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).url||"")}catch{process.stdout.write("")}\' "$URL_RESULT")\n URL_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("recap-url.txt URL validation failed")}\' "$URL_RESULT")\n else\n CANONICAL_URL=""\n fi\n if [ -n "$CANONICAL_URL" ]; then\n echo "plan_url=$CANONICAL_URL" >> "$GITHUB_OUTPUT"; echo "ok=true" >> "$GITHUB_OUTPUT"\n else\n echo "plan_url=" >> "$GITHUB_OUTPUT"; echo "ok=false" >> "$GITHUB_OUTPUT"\n fi\n {\n echo \'reason<<__RECAP_URL_REASON_EOF__\'\n echo "$URL_REASON"\n echo \'__RECAP_URL_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != \'true\' && steps.diff.outputs.tiny != \'true\' && steps.route_health.outputs.unhealthy != \'true\' && steps.scan.outputs.suppressed != \'true\'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == \'success\' }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n if [ "$RECAP_REPAIR_ATTEMPTED" = "true" ]; then\n RESULT=claude-repair-result.json\n STDERR=claude-repair-stderr.log\n EXIT_CODE=claude-repair-exit-code.txt\n if [ "$RECAP_AGENT" = "codex" ]; then\n RESULT=codex-repair-events.jsonl\n STDERR=codex-repair-stderr.log\n EXIT_CODE=codex-repair-exit-code.txt\n elif [ "$RECAP_AGENT" = "openai-compatible" ]; then\n RESULT=openai-compatible-repair-result.txt\n STDERR=openai-compatible-repair-stderr.log\n EXIT_CODE=openai-compatible-repair-exit-code.txt\n fi\n fi\n SUMMARY_JSON="$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent "$RECAP_AGENT" --result-file "$RESULT" --stderr-file "$STDERR" --exit-code-file "$EXIT_CODE" || echo \'{}\')"\n SUMMARY="$(node -e \'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === "string" ? value : ""); } catch {}\' "$SUMMARY_JSON")"\n if [ -n "$SUMMARY" ]; then\n {\n echo \'summary<<__RECAP_AGENT_SUMMARY_EOF__\'\n echo "$SUMMARY"\n echo \'__RECAP_AGENT_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_BLOCK_REFERENCE_SUMMARY:-}" ]; then\n {\n echo \'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n echo "$RECAP_BLOCK_REFERENCE_SUMMARY"\n echo \'__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n elif [ -n "${RECAP_PUBLISH_REASON:-}" ]; then\n {\n echo \'summary<<__RECAP_PUBLISH_SUMMARY_EOF__\'\n echo "$RECAP_PUBLISH_REASON"\n echo \'__RECAP_PUBLISH_SUMMARY_EOF__\'\n } >> "$GITHUB_OUTPUT"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so "Codex" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == \'success\' }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-usage.json; fi\n if [ "$RECAP_REPAIR_SUCCEEDED" = "true" ]; then\n RESULT=claude-repair-result.json\n if [ "$RECAP_AGENT" = "codex" ]; then RESULT=codex-repair-events.jsonl; fi\n if [ "$RECAP_AGENT" = "openai-compatible" ]; then RESULT=openai-compatible-repair-usage.json; fi\n fi\n if [ -f "$RESULT" ]; then $RECAP_CLI recap usage --plan-url "$PLAN_URL" --agent "$RECAP_AGENT" --result-file "$RESULT" --model "${VISUAL_RECAP_MODEL:-}" --app-url "$PLAN_RECAP_APP_URL" --token "$PLAN_RECAP_TOKEN" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == \'true\'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == \'true\'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n "${RECAP_PLAYWRIGHT:-}" ] && [ -x "$RECAP_PLAYWRIGHT" ]; then\n "$RECAP_PLAYWRIGHT" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY="$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"\n LIGHT_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap.png --theme light --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n DARK_SHOT_JSON="$($RECAP_CLI recap shot --url "$PLAN_URL" --token "$PLAN_RECAP_TOKEN" --app-url "$PLAN_RECAP_APP_URL" --out recap-dark.png --theme dark --image-cache-key "$IMAGE_CACHE_KEY" || echo \'{}\')"\n for SHOT_LABEL in light dark; do\n if [ "$SHOT_LABEL" = "light" ]; then SHOT_JSON="$LIGHT_SHOT_JSON"; else SHOT_JSON="$DARK_SHOT_JSON"; fi\n SHOT_LABEL="$SHOT_LABEL" SHOT_JSON="$SHOT_JSON" node -e \'const label = process.env.SHOT_LABEL || "shot"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || "{}"); } catch { parsed = { ok: false, reason: "invalid shot JSON" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? "[present]" : "", out: typeof parsed.out === "string" ? parsed.out : "", reason: typeof parsed.reason === "string" ? parsed.reason.slice(0, 500) : "" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);\'\n done\n IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$LIGHT_SHOT_JSON")\n DARK_IMAGE_URL=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||"")}catch{process.stdout.write("")}\' "$DARK_SHOT_JSON")\n SHOT_STATUS=$(LIGHT_SHOT_JSON="$LIGHT_SHOT_JSON" DARK_SHOT_JSON="$DARK_SHOT_JSON" node <<\'NODE\'\n const parse = (raw) => { try { return JSON.parse(raw || "{}"); } catch { return { ok: false, reason: "invalid shot JSON" }; } };\n const shots = [["light", parse(process.env.LIGHT_SHOT_JSON)], ["dark", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === "string" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === "string" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === "string" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? "" : reasons.join("; ").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?"true":"false")}catch{process.stdout.write("false")}\' "$SHOT_STATUS")\n SHOT_REASON=$(node -e \'try{process.stdout.write(JSON.parse(process.argv[1]).reason||"")}catch{process.stdout.write("invalid shot status JSON")}\' "$SHOT_STATUS")\n if [ "$SHOT_OK" != "true" ]; then\n echo "::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON"\n fi\n echo "image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "light_image_url=$IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "dark_image_url=$DARK_IMAGE_URL" >> "$GITHUB_OUTPUT"\n echo "shot_ok=$SHOT_OK" >> "$GITHUB_OUTPUT"\n {\n echo \'shot_reason<<__RECAP_SHOT_REASON_EOF__\'\n echo "$SHOT_REASON"\n echo \'__RECAP_SHOT_REASON_EOF__\'\n } >> "$GITHUB_OUTPUT"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo "captured=true" >> "$GITHUB_OUTPUT"; else echo "captured=false" >> "$GITHUB_OUTPUT"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == \'true\'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) — INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n recap-source.initial.json\n recap-url-reason.txt\n recap-repair-prompt.md\n claude-result.json\n claude-stderr.log\n claude-repair-result.json\n claude-repair-stderr.log\n claude-repair-exit-code.txt\n codex-events.jsonl\n codex-stderr.log\n codex-repair-events.jsonl\n codex-repair-stderr.log\n codex-repair-exit-code.txt\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n openai-compatible-repair-result.txt\n openai-compatible-repair-usage.json\n openai-compatible-repair-stderr.log\n openai-compatible-repair-exit-code.txt\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # "recap-url.txt was not created" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo "$GITHUB_REPOSITORY" --issue "$PR_NUMBER" --token "$GH_TOKEN" --head-sha "$HEAD_SHA"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != \'\'\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id "$CHECK_RUN_ID" \\\n --plan-ok "$PLAN_OK" \\\n --plan-url "$PLAN_URL" \\\n --suppressed "$SUPPRESSED" \\\n --suppressed-json "$SUPPRESSED_JSON" \\\n --huge "$DIFF_HUGE" \\\n --tiny "$DIFF_TINY" \\\n --failure-summary "$RECAP_AGENT_SUMMARY" \\\n --url-reason "$RECAP_URL_REASON" \\\n --workflow-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"\n';
@@ -332,6 +332,19 @@ export { loadRunCodeToolEntries };
332
332
  export { shouldDisableRecurringJobsRuntime };
333
333
  export { finalizeClaimedAgentChatProcessRunFailure };
334
334
 
335
+ export function resolveInteractiveAgentRunOptions(
336
+ options?: Pick<
337
+ AgentChatPluginOptions,
338
+ "runSoftTimeoutMs" | "runNoProgressTimeoutMs" | "durableBackgroundRuns"
339
+ >,
340
+ ) {
341
+ return {
342
+ runSoftTimeoutMs: options?.runSoftTimeoutMs,
343
+ runNoProgressTimeoutMs: options?.runNoProgressTimeoutMs,
344
+ durableBackgroundRuns: options?.durableBackgroundRuns,
345
+ };
346
+ }
347
+
335
348
  export function createSerializedA2ATaskStatusWriter(
336
349
  taskId: string,
337
350
  writeStatus: (
@@ -2909,9 +2922,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
2909
2922
  model: options?.model,
2910
2923
  appId: options?.appId,
2911
2924
  apiKey: options?.apiKey,
2912
- runSoftTimeoutMs: options?.runSoftTimeoutMs,
2913
- runNoProgressTimeoutMs: options?.runNoProgressTimeoutMs,
2914
- durableBackgroundRuns: options?.durableBackgroundRuns,
2925
+ ...resolveInteractiveAgentRunOptions(options),
2915
2926
  finalResponseGuard: options?.finalResponseGuard,
2916
2927
  prepareRequest: async (details) => {
2917
2928
  if (details.threadId && details.ownerEmail) {
@@ -3017,9 +3028,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
3017
3028
  model: options?.model,
3018
3029
  appId: options?.appId,
3019
3030
  apiKey: options?.apiKey,
3020
- runSoftTimeoutMs: options?.runSoftTimeoutMs,
3021
- runNoProgressTimeoutMs: options?.runNoProgressTimeoutMs,
3022
- durableBackgroundRuns: options?.durableBackgroundRuns,
3031
+ ...resolveInteractiveAgentRunOptions(options),
3023
3032
  finalResponseGuard: options?.finalResponseGuard,
3024
3033
  prepareRequest: options?.prepareRequest,
3025
3034
  skipFilesContext: true,
@@ -3176,9 +3185,7 @@ Non-code requests are still fine on this surface: read data, navigate the UI, su
3176
3185
  model: options?.model,
3177
3186
  appId: options?.appId,
3178
3187
  apiKey: options?.apiKey,
3179
- runSoftTimeoutMs: options?.runSoftTimeoutMs,
3180
- runNoProgressTimeoutMs: options?.runNoProgressTimeoutMs,
3181
- durableBackgroundRuns: options?.durableBackgroundRuns,
3188
+ ...resolveInteractiveAgentRunOptions(options),
3182
3189
  finalResponseGuard: options?.finalResponseGuard,
3183
3190
  prepareRequest: async (details) => {
3184
3191
  if (details.threadId && details.ownerEmail) {
@@ -26,6 +26,7 @@ import {
26
26
  type CSSProperties,
27
27
  type ReactNode,
28
28
  } from "react";
29
+ import { createPortal } from "react-dom";
29
30
  import { Link } from "react-router";
30
31
  import {
31
32
  Area,
@@ -60,7 +61,7 @@ import {
60
61
  SelectValue,
61
62
  } from "@/components/ui/select";
62
63
  import { Skeleton } from "@/components/ui/skeleton";
63
- import { useChartTooltipFlip } from "@/hooks/use-chart-tooltip-flip";
64
+ import { useChartTooltipPortalPosition } from "@/hooks/use-chart-tooltip-portal";
64
65
 
65
66
  const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
66
67
  import { createDemoChartTrendRows } from "@/lib/demo-chart-trend";
@@ -86,7 +87,7 @@ const DEFAULT_COLORS = [
86
87
  ];
87
88
 
88
89
  const CHART_TOOLTIP_WRAPPER_STYLE: CSSProperties = {
89
- zIndex: 60,
90
+ zIndex: 9999,
90
91
  pointerEvents: "none",
91
92
  };
92
93
 
@@ -897,6 +898,7 @@ export function ChartTooltip({
897
898
  active,
898
899
  payload,
899
900
  label,
901
+ coordinate,
900
902
  labelFormatter,
901
903
  seriesNameFormatter,
902
904
  valueFormatter,
@@ -909,11 +911,11 @@ export function ChartTooltip({
909
911
  value?: unknown;
910
912
  }>;
911
913
  label?: unknown;
914
+ coordinate?: { x?: number; y?: number };
912
915
  labelFormatter?: (value: string) => string;
913
916
  seriesNameFormatter?: (value: string) => string;
914
917
  valueFormatter?: (value: number) => string;
915
918
  }) {
916
- const tooltipRef = useChartTooltipFlip<HTMLDivElement>(active);
917
919
  const items = useMemo(
918
920
  () =>
919
921
  sortTooltipPayloadItems(
@@ -922,6 +924,11 @@ export function ChartTooltip({
922
924
  ),
923
925
  [payload],
924
926
  );
927
+ const isVisible = Boolean(active) && items.length > 0;
928
+ const { anchorRef, boxRef } = useChartTooltipPortalPosition(
929
+ isVisible,
930
+ coordinate,
931
+ );
925
932
 
926
933
  const labelText =
927
934
  label == null
@@ -930,13 +937,13 @@ export function ChartTooltip({
930
937
  ? labelFormatter(String(label))
931
938
  : String(label);
932
939
 
933
- if (!active || items.length === 0) return null;
940
+ if (!isVisible) return null;
934
941
 
935
942
  const tooltip = (
936
943
  <div
937
- ref={tooltipRef}
944
+ ref={boxRef}
938
945
  role="tooltip"
939
- className="min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg"
946
+ className="fixed z-[9999] min-w-40 max-w-[280px] rounded-md border border-border bg-card px-3 py-2 text-xs text-foreground shadow-lg pointer-events-none"
940
947
  >
941
948
  {labelText && (
942
949
  <div className="mb-1.5 truncate font-medium text-foreground">
@@ -971,7 +978,12 @@ export function ChartTooltip({
971
978
  </div>
972
979
  );
973
980
 
974
- return tooltip;
981
+ return (
982
+ <>
983
+ <span ref={anchorRef} aria-hidden="true" />
984
+ {createPortal(tooltip, document.body)}
985
+ </>
986
+ );
975
987
  }
976
988
 
977
989
  function detectKeys(
@@ -0,0 +1,60 @@
1
+ import { useLayoutEffect, useRef } from "react";
2
+
3
+ const CHART_EDGE_PADDING = 8;
4
+ const CURSOR_OFFSET = 14;
5
+
6
+ type TooltipCoordinate = { x?: number; y?: number } | undefined;
7
+
8
+ /**
9
+ * Ancestors of a dashboard chart (the scrollable app shell, the dashboard
10
+ * grid's inline-size container) clip any content that overflows their box,
11
+ * so the tooltip content is rendered through a portal to `document.body`.
12
+ * Recharts positions its own tooltip wrapper by measuring that wrapper's
13
+ * child content size — with the real content portaled away the wrapper has
14
+ * no size to measure and never gets a transform, so we can't read a live
15
+ * position off it. Instead, position the portaled box ourselves from the
16
+ * `coordinate` Recharts already passes to custom tooltip content (the exact
17
+ * pixel the cursor is over, relative to the chart) plus the chart's own
18
+ * `.recharts-wrapper` rect, and clamp against that same rect so the tooltip
19
+ * tracks the cursor but never crosses the chart's own edges.
20
+ */
21
+ export function useChartTooltipPortalPosition(
22
+ isVisible: boolean,
23
+ coordinate: TooltipCoordinate,
24
+ ) {
25
+ const anchorRef = useRef<HTMLSpanElement | null>(null);
26
+ const boxRef = useRef<HTMLDivElement | null>(null);
27
+
28
+ useLayoutEffect(() => {
29
+ if (!isVisible) return;
30
+ const anchor = anchorRef.current;
31
+ const box = boxRef.current;
32
+ if (!anchor || !box) return;
33
+ const chartEl = anchor.closest(".recharts-wrapper");
34
+ if (!chartEl) return;
35
+
36
+ const chartRect = chartEl.getBoundingClientRect();
37
+ const boxRect = box.getBoundingClientRect();
38
+
39
+ const pointX = chartRect.left + (coordinate?.x ?? 0);
40
+ const pointY = chartRect.top + (coordinate?.y ?? 0);
41
+
42
+ const minLeft = chartRect.left + CHART_EDGE_PADDING;
43
+ const maxLeft = chartRect.right - CHART_EDGE_PADDING - boxRect.width;
44
+ let left = pointX + CURSOR_OFFSET;
45
+ if (left > maxLeft) left = pointX - CURSOR_OFFSET - boxRect.width;
46
+ left = Math.min(Math.max(left, minLeft), Math.max(minLeft, maxLeft));
47
+
48
+ const minTop = chartRect.top + CHART_EDGE_PADDING;
49
+ const maxTop = chartRect.bottom - CHART_EDGE_PADDING - boxRect.height;
50
+ const top = Math.min(
51
+ Math.max(pointY - boxRect.height / 2, minTop),
52
+ Math.max(minTop, maxTop),
53
+ );
54
+
55
+ box.style.left = `${left}px`;
56
+ box.style.top = `${top}px`;
57
+ }, [isVisible, coordinate?.x, coordinate?.y]);
58
+
59
+ return { anchorRef, boxRef };
60
+ }
@@ -105,7 +105,7 @@ export default function ChartRoute() {
105
105
  }
106
106
 
107
107
  return (
108
- <div className="flex h-screen w-screen flex-col overflow-hidden bg-transparent p-2">
108
+ <div className="flex h-screen w-screen flex-col overflow-visible bg-transparent p-2">
109
109
  {result.title && (
110
110
  <div className="mb-1 px-1 text-xs font-medium text-muted-foreground">
111
111
  {result.title}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-20
4
+ ---
5
+
6
+ Fixed Analytics dashboard requests that stall without visible progress.
@@ -34,6 +34,11 @@ import {
34
34
  } from "../lib/real-data-actions";
35
35
 
36
36
  const ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS = 13 * 60_000;
37
+ // A background job may legitimately spend minutes inside a provider/tool call,
38
+ // which the shared watchdog already excludes. Outside a tool call, however,
39
+ // silence means the model transport or worker has wedged; recover the chunk
40
+ // promptly instead of holding the dashboard composer for the 12-minute default.
41
+ export const ANALYTICS_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS = 3 * 60_000;
37
42
 
38
43
  const ANALYTICS_DATA_SOURCES_LINK = buildDeepLink({
39
44
  app: "analytics",
@@ -684,6 +689,7 @@ export default createAgentChatPlugin({
684
689
  // standard serverless request budget without orphaning the task.
685
690
  durableBackgroundRuns: true,
686
691
  runSoftTimeoutMs: ANALYTICS_BACKGROUND_RUN_SOFT_TIMEOUT_MS,
692
+ runNoProgressTimeoutMs: ANALYTICS_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS,
687
693
  connectorCatalog: [...ANALYTICS_CONNECTOR_CATALOG],
688
694
  externalAgents: {
689
695
  // Keep the direct MCP surface deliberately curated. External agents
@@ -1,5 +1,11 @@
1
1
  # @agent-native/toolkit
2
2
 
3
+ ## 0.8.2
4
+
5
+ ### Patch Changes
6
+
7
+ - dcd0810: Add clear creation actions to empty resource views and improve collaboration usage feedback.
8
+
3
9
  ## 0.8.1
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Reusable app-building UI and helpers for Agent-Native apps.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -1,3 +1,3 @@
1
- /** Compatibility re-export for the workflow bundled by the recap CLI package. */
2
- export { PR_VISUAL_RECAP_WORKFLOW_YML } from "@agent-native/recap-cli";
1
+ /** Canonical PR Visual Recap workflow bundled by the CLI installer. */
2
+ export declare const PR_VISUAL_RECAP_WORKFLOW_YML = "name: PR Visual Recap\n\n# Visual code review: a coding agent runs the repo's visual-recap skill over the\n# PR diff, publishes a plan, and upserts one sticky comment with a screenshot.\n# Plain `pull_request` (NOT `pull_request_target`) so fork code never sees secrets.\n\non:\n pull_request:\n types: [opened, synchronize, reopened, ready_for_review, closed]\n\npermissions:\n contents: read\n\nconcurrency:\n group: pr-visual-recap-${{ github.event.pull_request.number }}\n cancel-in-progress: true\n\nenv:\n VISUAL_RECAP_AGENT: ${{ vars.VISUAL_RECAP_AGENT || 'claude' }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n\njobs:\n gate:\n name: Gate\n # A custom plain-label runner is allowed only for trusted same-repo authors.\n # Fork and untrusted PRs are forced onto GitHub-hosted ubuntu-latest before\n # any step starts. The only fromJSON input is this static association list.\n runs-on: ${{ github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('[\"OWNER\",\"MEMBER\",\"COLLABORATOR\"]'), github.event.pull_request.author_association) && (vars.VISUAL_RECAP_GATE_RUNS_ON || 'ubuntu-latest') || 'ubuntu-latest' }}\n timeout-minutes: 10\n permissions:\n contents: read\n issues: write\n pull-requests: write\n outputs:\n run: ${{ steps.decide.outputs.run }}\n agent: ${{ steps.decide.outputs.agent }}\n runs_on: ${{ steps.decide.outputs.runs_on }}\n steps:\n - id: decide\n uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0\n env:\n # Presence-only signals \u2014 never expose secret VALUES to the gate.\n HAS_PLAN: ${{ secrets.PLAN_RECAP_TOKEN != '' }}\n HAS_ANTHROPIC: ${{ secrets.ANTHROPIC_API_KEY != '' }}\n HAS_OPENAI: ${{ secrets.OPENAI_API_KEY != '' }}\n HAS_COMPATIBLE: ${{ secrets.VISUAL_RECAP_API_KEY != '' }}\n AGENT: ${{ env.VISUAL_RECAP_AGENT }}\n VISUAL_RECAP_BASE_URL: ${{ env.VISUAL_RECAP_BASE_URL }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_RUNS_ON: ${{ vars.VISUAL_RECAP_RUNS_ON || '\"ubuntu-latest\"' }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ env.VISUAL_RECAP_SKILL_SOURCE }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n with:\n script: |\n const pr = context.payload.pull_request;\n const reasons = [];\n\n if (!pr) reasons.push('no pull_request payload');\n if (pr && pr.draft) reasons.push('draft PR');\n if (pr && context.payload.action === 'closed' && !pr.merged) {\n reasons.push('closed without merge');\n }\n\n // Fork PRs only receive repo secrets when the org/repo opts into\n // GitHub's \"Send secrets to workflows from pull requests\" setting\n // (common in private orgs that use forks heavily). Gate on secret\n // availability, not fork-ness: run on forks that have the token,\n // and skip \u2014 with an actionable hint \u2014 those that don't.\n const headRepo = pr && pr.head && pr.head.repo && pr.head.repo.full_name;\n const isFork = !!(pr && headRepo && headRepo !== process.env.GITHUB_REPOSITORY);\n const isPrivate = !!(context.payload.repository && context.payload.repository.private);\n const association = (pr && pr.author_association || '').toUpperCase();\n const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];\n const isTrustedAuthor = trustedAssociations.includes(association);\n let configuredRunner = 'ubuntu-latest';\n let usesSelfHostedRunner = false;\n try {\n const candidate = JSON.parse(process.env.VISUAL_RECAP_RUNS_ON || '\"ubuntu-latest\"');\n const hosted = typeof candidate === 'string' && /^(?:ubuntu|windows|macos)-[A-Za-z0-9.-]+$/.test(candidate);\n const selfHosted = Array.isArray(candidate) && candidate.length >= 1 && candidate.length <= 20 && candidate.includes('self-hosted') && candidate.every((label) => typeof label === 'string' && label.length >= 1 && label.length <= 100 && !/[\\u0000-\\u001f\\u007f]/.test(label)) && new Set(candidate).size === candidate.length;\n if (!hosted && !selfHosted) throw new Error('unsupported runner value');\n configuredRunner = candidate;\n usesSelfHostedRunner = selfHosted;\n } catch {\n reasons.push('invalid VISUAL_RECAP_RUNS_ON JSON');\n }\n if (usesSelfHostedRunner && (isFork || !isTrustedAuthor)) {\n reasons.push('self-hosted runner mode requires a trusted same-repository PR author');\n }\n if (isFork && process.env.HAS_PLAN !== 'true') {\n reasons.push(`fork PR (${headRepo}) without secret access \u2014 enable \"Send secrets to workflows from pull requests\" (and write tokens) in the repo/org Actions settings to run recaps on forks`);\n }\n\n const login = (pr && pr.user && pr.user.login || '').toLowerCase();\n const botAuthors = ['dependabot[bot]', 'dependabot', 'renovate[bot]', 'renovate'];\n if (botAuthors.includes(login)) reasons.push(`bot author (${login})`);\n if (pr && pr.user && pr.user.type === 'Bot') reasons.push('bot author (type=Bot)');\n\n if (!isFork && process.env.HAS_PLAN !== 'true') reasons.push('PLAN_RECAP_TOKEN not configured');\n\n // Normalize + validate the agent so a mis-cased value can't pass the\n // gate and then match neither agent step below.\n const rawAgent = (process.env.AGENT || 'claude').toLowerCase();\n const agent = ['deepseek', 'kimi', 'moonshot', 'custom'].includes(rawAgent) ? 'openai-compatible' : rawAgent;\n if (!['claude', 'codex', 'openai-compatible'].includes(agent)) {\n reasons.push(`unsupported VISUAL_RECAP_AGENT \"${process.env.AGENT}\" (expected \"claude\", \"codex\", or \"openai-compatible\")`);\n } else if (agent === 'codex') {\n if (process.env.HAS_OPENAI !== 'true') reasons.push('OPENAI_API_KEY not configured (codex backend)');\n } else if (agent === 'claude') {\n if (process.env.HAS_ANTHROPIC !== 'true') reasons.push('ANTHROPIC_API_KEY not configured (claude backend)');\n } else {\n if (process.env.HAS_COMPATIBLE !== 'true') reasons.push('VISUAL_RECAP_API_KEY not configured (openai-compatible backend)');\n if (!(process.env.VISUAL_RECAP_MODEL || '').trim()) reasons.push('VISUAL_RECAP_MODEL is required (openai-compatible backend)');\n const baseUrl = process.env.VISUAL_RECAP_BASE_URL || '';\n try {\n const parsed = new URL(baseUrl);\n if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {\n reasons.push('VISUAL_RECAP_BASE_URL must be an http(s) URL without credentials');\n }\n } catch {\n reasons.push('VISUAL_RECAP_BASE_URL must be a valid http(s) URL');\n }\n }\n\n // Validate the model before it reaches the agent CLI.\n const model = process.env.VISUAL_RECAP_MODEL || '';\n if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {\n reasons.push(`invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})`);\n }\n\n const skillSource = (process.env.VISUAL_RECAP_SKILL_SOURCE || 'auto').toLowerCase();\n if (!['auto', 'latest', 'repo'].includes(skillSource)) {\n reasons.push('invalid VISUAL_RECAP_SKILL_SOURCE value (expected \"auto\", \"latest\", or \"repo\")');\n }\n const usesRepoSkill = skillSource === 'repo';\n\n // Self-modifying guard, evaluated in the trusted gate (runs NO\n // PR-checked-out code): skip the ENTIRE job if the PR touches the\n // repo-pinned skill instructions or any agent config the runner\n // loads, so a PR can't rewrite what the agent loads and exfiltrate\n // secrets. With the default bundled skill source, visual skill and\n // recap workflow files are reviewed content, not instructions loaded\n // by the runner.\n // Keep this guard for untrusted forks and untrusted public-repo PRs.\n // Trusted write actors may edit recap-control files as normal\n // reviewable content; running the recap is useful signal for those\n // changes.\n if (pr && !isTrustedAuthor && (isFork || !isPrivate)) {\n try {\n const files = await github.paginate(github.rest.pulls.listFiles, {\n owner: context.repo.owner,\n repo: context.repo.repo,\n pull_number: pr.number,\n per_page: 100,\n });\n const isSensitive = (p) =>\n (usesRepoSkill && /(^|\\/)skills\\/visual-(recap|plan|plans)\\//.test(p)) ||\n p.startsWith('.claude/') ||\n p === 'CLAUDE.md' ||\n p === 'AGENTS.md' ||\n p === '.mcp.json';\n const hits = files.map((f) => f.filename).filter(isSensitive);\n if (hits.length) {\n reasons.push(`PR modifies recap-control files (${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ', \u2026' : ''}) \u2014 skipping so untrusted PR code never runs with secrets`);\n }\n } catch (e) {\n // Fail closed: if the file list can't be read, skip.\n reasons.push(`could not list PR files for the self-modifying guard (${e.message}); skipping to be safe`);\n }\n }\n\n const run = reasons.length === 0;\n core.setOutput('run', run ? 'true' : 'false');\n core.setOutput('agent', agent);\n core.setOutput('runs_on', JSON.stringify(configuredRunner));\n if (run) {\n core.info(`Visual recap will run (${agent}).`);\n } else {\n // Surface the skip reason as a run-summary annotation, not just a\n // buried info log, so it's clear in the Actions UI why we skipped.\n core.notice(`Visual recap skipped: ${reasons.join('; ')}`);\n }\n\n // When skipping, upsert a sticky recap comment with a short skip\n // line so the PR always explains why the recap job did not run.\n if (!run && pr) {\n try {\n const MARKER = '<!-- pr-visual-recap -->';\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n per_page: 100,\n });\n const existing = comments.find(\n (c) => c.user && c.user.type === 'Bot' && c.body && c.body.includes(MARKER)\n );\n const headShort = (process.env.HEAD_SHA || '').slice(0, 7);\n const shaRef = headShort ? `\\`${headShort}\\`` : 'latest push';\n const primaryReason = reasons.filter(\n (r) => !r.startsWith('could not list PR files for the self-modifying guard')\n )[0] || reasons[0] || 'skipped';\n const skipLine = `_Recap skipped for ${shaRef}: ${primaryReason}._`;\n const baseBody = `${MARKER}\\n### Visual recap \u2014 skipped\\n\\nThe visual recap job did not run for this pull request. This is informational only and does **not** block the PR.`;\n const planIdMatch = (existing && existing.body ? existing.body : '').match(/<!--\\s*plan-id:\\s*([A-Za-z0-9_-]{1,64})\\s*-->/);\n const planIdMarker = planIdMatch ? `\\n\\n<!-- plan-id: ${planIdMatch[1]} -->` : '';\n const updatedBody = `${baseBody}${planIdMarker}\\n\\n${skipLine}`;\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body: updatedBody,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: pr.number,\n body: updatedBody,\n });\n }\n } catch (e) {\n core.warning(`Could not update recap skip comment: ${e.message}`);\n }\n }\n\n recap:\n name: Generate visual recap\n needs: gate\n if: needs.gate.outputs.run == 'true'\n runs-on: ${{ fromJSON(needs.gate.outputs.runs_on) }}\n timeout-minutes: 30\n defaults:\n run:\n shell: bash\n permissions:\n actions: write\n checks: write\n contents: read\n issues: write\n pull-requests: write\n env:\n PLAN_RECAP_APP_URL: ${{ secrets.PLAN_RECAP_APP_URL || 'https://plan.agent-native.com' }}\n PLAN_RECAP_TOKEN: ${{ secrets.PLAN_RECAP_TOKEN }}\n GH_TOKEN: ${{ github.token }}\n PR_NUMBER: ${{ github.event.pull_request.number }}\n PR_STATE: ${{ github.event.pull_request.state }}\n PR_MERGED: ${{ github.event.pull_request.merged }}\n PR_MERGED_AT: ${{ github.event.pull_request.merged_at }}\n HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n VISUAL_RECAP_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n VISUAL_RECAP_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL || '' }}\n VISUAL_RECAP_REASONING: ${{ vars.VISUAL_RECAP_REASONING }}\n VISUAL_RECAP_SKILL_SOURCE: ${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n VISUAL_RECAP_SECRET_SCAN: ${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n steps:\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n with:\n fetch-depth: 0\n # This job runs an agent over untrusted PR diff; don't leave the token\n # in .git/config (it uses GH_TOKEN for gh API calls, never git push).\n persist-credentials: false\n\n # Dogfood trusted base-branch source inside this monorepo, else install the\n # published package once. Never execute PR-head recap CLI code.\n - name: Resolve recap CLI\n id: cli\n env:\n # Optional: pin the consumer CLI version (e.g. \"1.2.3\"). Defaults to\n # \"latest\" when unset. Set via repository variable RECAP_CLI_VERSION.\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}\n run: |\n if [ \"$GITHUB_REPOSITORY\" = \"BuilderIO/agent-native\" ] && [ -f packages/core/src/cli/index.ts ]; then\n echo \"local=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"local=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3\n if: steps.cli.outputs.local == 'true'\n with:\n ref: ${{ github.event.pull_request.base.sha }}\n path: .recap-cli-source\n fetch-depth: 1\n persist-credentials: false\n\n - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8\n if: steps.cli.outputs.local == 'true'\n\n - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0\n with:\n node-version: \"22\"\n cache: ${{ steps.cli.outputs.local == 'true' && 'pnpm' || '' }}\n\n - name: Install trusted workspace recap CLI\n if: steps.cli.outputs.local == 'true'\n working-directory: .recap-cli-source\n run: |\n set -euo pipefail\n pnpm install --frozen-lockfile --ignore-scripts\n pnpm --filter @agent-native/recap-cli build\n echo \"RECAP_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"CODE_CLI=$PWD/node_modules/.bin/tsx $PWD/packages/core/src/cli/index.ts\" >> \"$GITHUB_ENV\"\n echo \"RECAP_PLAYWRIGHT=$PWD/node_modules/.bin/playwright\" >> \"$GITHUB_ENV\"\n\n - name: Install published recap CLI\n if: steps.cli.outputs.local != 'true'\n env:\n RECAP_CLI_VERSION: ${{ vars.RECAP_CLI_VERSION || 'latest' }}\n run: |\n set -euo pipefail\n VERSION=\"$RECAP_CLI_VERSION\"\n if [ \"$VERSION\" = \"latest\" ]; then\n VERSION=\"$(npm view @agent-native/recap-cli@latest version)\"\n fi\n for attempt in 1 2 3; do\n if npm install --prefix \"$RUNNER_TEMP/recap-cli\" --no-audit --no-fund --ignore-scripts \"@agent-native/recap-cli@$VERSION\"; then\n break\n fi\n if [ \"$attempt\" = \"3\" ]; then exit 1; fi\n sleep $((attempt * 10))\n done\n echo \"RECAP_CLI_VERSION=$VERSION\" >> \"$GITHUB_ENV\"\n echo \"RECAP_CLI=$RUNNER_TEMP/recap-cli/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\n echo \"RECAP_PLAYWRIGHT=$RUNNER_TEMP/recap-cli/node_modules/.bin/playwright\" >> \"$GITHUB_ENV\"\n\n - name: Install OpenAI-compatible provider runtime\n if: needs.gate.outputs.agent == 'openai-compatible' && steps.cli.outputs.local != 'true'\n env:\n CORE_CLI_VERSION: ${{ vars.CORE_CLI_VERSION || 'latest' }}\n run: |\n set -euo pipefail\n CORE_VERSION=\"$CORE_CLI_VERSION\"\n if [ \"$CORE_VERSION\" = \"latest\" ]; then\n CORE_VERSION=\"$(npm view @agent-native/core@latest version)\"\n fi\n npm install --prefix \"$RUNNER_TEMP/recap-code\" --no-audit --no-fund --ignore-scripts ai @ai-sdk/openai \"@agent-native/core@$CORE_VERSION\"\n echo \"CORE_CLI_VERSION=$CORE_VERSION\" >> \"$GITHUB_ENV\"\n echo \"CODE_CLI=$RUNNER_TEMP/recap-code/node_modules/.bin/agent-native\" >> \"$GITHUB_ENV\"\n\n - name: Start visual recap check\n id: recap_check\n continue-on-error: true\n run: |\n set -uo pipefail\n $RECAP_CLI recap check start --sha \"$HEAD_SHA\" --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\n\n - name: Fetch pull request head\n env:\n PR_NUMBER_ENV: ${{ github.event.pull_request.number }}\n run: |\n set -euo pipefail\n if git cat-file -e \"${HEAD_SHA}^{commit}\" 2>/dev/null; then\n git update-ref refs/recap/pr-head \"$HEAD_SHA\"\n else\n AUTH_B64=\"$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 | tr -d '\\n')\"\n git -c \"http.https://github.com/.extraheader=AUTHORIZATION: basic $AUTH_B64\" fetch origin \"pull/${PR_NUMBER_ENV}/head:refs/recap/pr-head\"\n fi\n FETCHED_SHA=\"$(git rev-parse refs/recap/pr-head)\"\n if [ \"$FETCHED_SHA\" != \"$HEAD_SHA\" ]; then\n echo \"FATAL: fetched PR head $FETCHED_SHA != event HEAD_SHA $HEAD_SHA \u2014 aborting to avoid recapping the wrong commit\"\n exit 1\n fi\n\n - name: Collect bounded diff\n id: diff\n env:\n BASE_SHA: ${{ github.event.pull_request.base.sha }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap collect-diff --base \"$BASE_SHA\" --head refs/recap/pr-head --out recap.diff --stat recap.stat\n\n - name: Probe plan-app auth\n id: auth_probe\n if: steps.diff.outputs.tiny != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Hit the plan app's action surface with the publish token. A 401 means\n # the token is expired/revoked; surface it in the sticky comment so the\n # repo owner knows to re-mint it instead of seeing a generic failure.\n HTTP_STATUS=$(node -e '\n const https = require(\"https\");\n const url = new URL(\"/_agent-native/actions/record-recap-usage\", process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\n const req = https.request(url, { method: \"POST\", headers: { \"authorization\": \"Bearer \" + process.env.PLAN_RECAP_TOKEN, \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on(\"error\", () => process.stdout.write(\"0\"));\n req.end(JSON.stringify({ planId: \"__probe__\" }));\n ' 2>/dev/null || echo \"0\")\n if [ \"$HTTP_STATUS\" = \"401\" ]; then\n echo \"auth_failed=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"auth_failed=false\" >> \"$GITHUB_OUTPUT\"\n fi\n\n - name: Probe plan-app route health\n id: route_health\n if: steps.diff.outputs.tiny != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n # Pre-publish health gate: confirm the plan app's recap action routes\n # are actually deployed BEFORE the agent runs. A 404 from\n # create-visual-recap (POST) or get-plan-blocks (GET) means the\n # plan-app deploy has not propagated yet (the client is ahead of the\n # deployed server). Say that plainly here instead of letting the agent\n # run and then fail confusingly at publish time. A 401 or 200 is\n # healthy \u2014 the route exists, it just rejected/accepted the probe.\n probe_status() {\n ROUTE=\"$1\" METHOD=\"$2\" node -e '\n const https = require(\"https\");\n const base = process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\";\n const url = new URL(process.env.ROUTE, base);\n if (process.env.METHOD === \"GET\") url.searchParams.set(\"format\", \"reference\");\n const req = https.request(url, { method: process.env.METHOD, headers: { \"authorization\": \"Bearer \" + (process.env.PLAN_RECAP_TOKEN || \"\"), \"content-type\": \"application/json\" }, timeout: 8000 }, (res) => { process.stdout.write(String(res.statusCode)); req.destroy(); });\n req.on(\"error\", () => process.stdout.write(\"0\"));\n req.on(\"timeout\", () => { process.stdout.write(\"0\"); req.destroy(); });\n if (process.env.METHOD === \"POST\") { req.end(JSON.stringify({ __probe__: true })); } else { req.end(); }\n ' 2>/dev/null || echo \"0\"\n }\n CREATE_STATUS=\"$(probe_status /_agent-native/actions/create-visual-recap POST)\"\n BLOCKS_STATUS=\"$(probe_status /_agent-native/actions/get-plan-blocks GET)\"\n REASON=\"\"\n if [ \"$CREATE_STATUS\" = \"404\" ] || [ \"$BLOCKS_STATUS\" = \"404\" ]; then\n REASON=\"Plan app routes return 404 \u2014 deploy not yet propagated (create-visual-recap: $CREATE_STATUS, get-plan-blocks: $BLOCKS_STATUS). The plan-app client is ahead of the deployed server; re-run once the deploy finishes propagating.\"\n echo \"::error::$REASON\"\n echo \"unhealthy=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"unhealthy=false\" >> \"$GITHUB_OUTPUT\"\n fi\n {\n echo 'reason<<__RECAP_ROUTE_HEALTH_EOF__'\n echo \"$REASON\"\n echo '__RECAP_ROUTE_HEALTH_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n\n - name: Secret scan\n id: scan\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'\n run: |\n set -uo pipefail\n # Fail CLOSED: a scanner error or invalid JSON suppresses the diff so a\n # credential-bearing diff is never handed to the agent / plan service.\n if ! SCAN_JSON=\"$($RECAP_CLI recap scan --diff recap.diff --mode \"$VISUAL_RECAP_SECRET_SCAN\")\"; then\n SCAN_JSON='{\"suppressed\":true,\"reason\":\"secret scan failed to run; failing closed\"}'\n fi\n {\n echo 'json<<__RECAP_SCAN_EOF__'\n echo \"$SCAN_JSON\"\n echo '__RECAP_SCAN_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n SUPPRESSED=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).suppressed?\"true\":\"false\")}catch{process.stdout.write(\"true\")}' \"$SCAN_JSON\")\n echo \"suppressed=$SUPPRESSED\" >> \"$GITHUB_OUTPUT\"\n\n - name: Read previous plan id\n id: prev\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true'\n continue-on-error: true\n run: |\n set -euo pipefail\n PLAN_ID=\"$($RECAP_CLI recap comment find-plan-id --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\")\"\n echo \"plan_id=$PLAN_ID\" >> \"$GITHUB_OUTPUT\"\n\n - name: Fetch plan block reference\n id: block_reference\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n run: |\n set -uo pipefail\n if $RECAP_CLI recap block-reference --app-url \"$PLAN_RECAP_APP_URL\" --out recap-blocks.md; then\n echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\n {\n echo 'summary<<__RECAP_BLOCK_REFERENCE_EOF__'\n echo \"Could not fetch the live plan block reference; the agent will fall back to bundled visual-recap instructions and the hosted Plan action will validate the final MDX.\"\n echo '__RECAP_BLOCK_REFERENCE_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n cat > recap-blocks.md <<'EOF'\n Live plan block reference unavailable. Follow the bundled visual-recap skill and author conservative MDX; the deterministic publisher will validate the source before posting.\n EOF\n fi\n\n - name: Build recap prompt\n id: prompt\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n env:\n # Pass step outputs via env, NOT ${{ }} interpolation into the run body:\n # the prev plan id is parsed from a PR comment and could inject shell.\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}\n run: |\n set -euo pipefail\n ARGS=(--diff recap.diff --stat recap.stat --block-reference recap-blocks.md --pr \"$PR_NUMBER\" --repo \"$GITHUB_REPOSITORY\" --head \"$HEAD_SHA\" --app-url \"$PLAN_RECAP_APP_URL\" --skill-source \"$VISUAL_RECAP_SKILL_SOURCE\" --out recap-prompt.md)\n if [ \"${DIFF_HUGE:-}\" = \"true\" ]; then ARGS+=(--huge); fi\n if [ \"${IS_FORK:-}\" = \"true\" ]; then ARGS+=(--fork-pr true); fi\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\n $RECAP_CLI recap build-prompt \"${ARGS[@]}\"\n\n - name: Run agent (Claude Code)\n id: claude\n if: needs.gate.outputs.agent == 'claude' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS=\"Read,Write,Bash(git diff:*)\"\n CLAUDE_ARGS=(-p \"$(cat recap-prompt.md)\" --allowedTools \"$CLAUDE_ALLOWED_TOOLS\" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model \"${VISUAL_RECAP_MODEL:-claude-sonnet-5}\")\n rm -f recap-source.json recap-url.txt recap-url-reason.txt claude-result.json claude-stderr.log\n run_claude() {\n set +e\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-result.json 2> claude-stderr.log\n CLAUDE_STATUS=\"$?\"\n set -e\n echo \"$CLAUDE_STATUS\" > claude-exit-code.txt\n }\n run_claude\n # A clean agent exit WITHOUT recap-source.json is the strongest\n # \"retry me\" signal \u2014 the deterministic publisher needs that file, and\n # the agent occasionally finishes a turn without writing it. Retry once.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' claude-result.json claude-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_claude\n fi\n fi\n\n - name: Run agent (Codex)\n id: codex\n if: needs.gate.outputs.agent == 'codex' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n # `codex login` writes ~/.codex/auth.json (the bare env var is dropped on\n # the gpt-5.5 wss transport); stdin keeps the key out of process args.\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n # The runner is itself an ephemeral sandbox; bypass Codex's own sandbox\n # (bubblewrap can't init here) and approval gate (cancels the MCP write).\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CODEX_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n # Validate reasoning against the enum before embedding it in the TOML override.\n case \"${VISUAL_RECAP_REASONING:-}\" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c \"model_reasoning_effort=\\\"$VISUAL_RECAP_REASONING\\\"\") ;;\n \"\") ;;\n *) echo \"Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING\" ;;\n esac\n rm -f recap-source.json recap-url.txt recap-url-reason.txt codex-events.jsonl codex-stderr.log\n run_codex() {\n set +e\n npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-prompt.md)\" 2> codex-stderr.log | tee codex-events.jsonl\n CODEX_STATUS=\"${PIPESTATUS[0]}\"\n set -e\n echo \"$CODEX_STATUS\" > codex-exit-code.txt\n }\n run_codex\n # Retry once if the agent exited without writing recap-source.json\n # (see the Claude step) \u2014 the publisher needs that file.\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' codex-events.jsonl codex-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_codex\n fi\n fi\n\n - name: Run agent (OpenAI-compatible)\n id: openai_compatible\n if: needs.gate.outputs.agent == 'openai-compatible' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f recap-source.json recap-url.txt recap-url-reason.txt openai-compatible-result.txt openai-compatible-usage.json openai-compatible-stderr.log\n run_openai_compatible() {\n set +e\n $CODE_CLI code exec --permission-mode auto-edit \"$(cat recap-prompt.md)\" > openai-compatible-result.txt 2> openai-compatible-stderr.log\n OPENAI_COMPATIBLE_STATUS=\"$?\"\n set -e\n echo \"$OPENAI_COMPATIBLE_STATUS\" > openai-compatible-exit-code.txt\n }\n run_openai_compatible\n if [ ! -s recap-source.json ]; then\n if grep -Eiq -- 'quota exceeded|billing details|insufficient (credits|quota)|rate[-_ ]limit|invalid (api )?key|authentication (failed|error)|unauthorized|forbidden' openai-compatible-result.txt openai-compatible-stderr.log 2>/dev/null; then\n echo \"::error::Visual recap agent failed with a non-retryable provider error; skipping the duplicate retry.\"\n else\n echo \"::warning::recap-source.json missing after the agent run; retrying the agent once.\"\n sleep 5\n run_openai_compatible\n fi\n fi\n\n - name: Publish recap source\n id: publish\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo \"$GITHUB_REPOSITORY\" --pr \"$PR_NUMBER\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\")\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\n ARGS+=(--source-type pull-request --source-repo \"$GITHUB_REPOSITORY\" --source-pr-number \"$PR_NUMBER\")\n if [ \"${PR_MERGED:-false}\" = \"true\" ] || [ -n \"${PR_MERGED_AT:-}\" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n \"${PR_STATE:-}\" ]; then\n ARGS+=(--source-pr-state \"$PR_STATE\")\n fi\n if [ -n \"${PR_MERGED_AT:-}\" ]; then ARGS+=(--source-pr-merged-at \"$PR_MERGED_AT\"); fi\n $RECAP_CLI recap publish \"${ARGS[@]}\"\n\n - name: Build one-shot recap repair prompt\n id: repair_prompt\n if: steps.publish.outputs.repairable == 'true'\n run: |\n set -euo pipefail\n cp recap-source.json recap-source.initial.json\n $RECAP_CLI recap repair-prompt --source recap-source.json --reason-file recap-url-reason.txt --out recap-repair-prompt.md\n\n - name: Repair recap source (Claude Code)\n id: claude_repair\n if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'claude'\n continue-on-error: true\n env:\n ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n run: |\n set -uo pipefail\n CLAUDE_ALLOWED_TOOLS=\"Read,Write\"\n CLAUDE_ARGS=(-p \"$(cat recap-repair-prompt.md)\" --allowedTools \"$CLAUDE_ALLOWED_TOOLS\" --permission-mode dontAsk --output-format json)\n CLAUDE_ARGS+=(--model \"${VISUAL_RECAP_MODEL:-claude-sonnet-5}\")\n rm -f claude-repair-result.json claude-repair-stderr.log\n set +e\n npx -y @anthropic-ai/claude-code@2 \"${CLAUDE_ARGS[@]}\" > claude-repair-result.json 2> claude-repair-stderr.log\n CLAUDE_REPAIR_STATUS=\"$?\"\n set -e\n echo \"$CLAUDE_REPAIR_STATUS\" > claude-repair-exit-code.txt\n if [ \"$CLAUDE_REPAIR_STATUS\" -eq 0 ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n - name: Repair recap source (Codex)\n id: codex_repair\n if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'codex'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n run: |\n set -uo pipefail\n printenv OPENAI_API_KEY | npx -y @openai/codex@0 login --with-api-key || true\n CODEX_ARGS=(exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check)\n if [ -n \"${VISUAL_RECAP_MODEL:-}\" ]; then CODEX_ARGS+=(--model \"$VISUAL_RECAP_MODEL\"); fi\n case \"${VISUAL_RECAP_REASONING:-}\" in\n none|minimal|low|medium|high|xhigh)\n CODEX_ARGS+=(-c \"model_reasoning_effort=\\\"$VISUAL_RECAP_REASONING\\\"\") ;;\n \"\") ;;\n *) echo \"Ignoring invalid VISUAL_RECAP_REASONING: $VISUAL_RECAP_REASONING\" ;;\n esac\n rm -f codex-repair-events.jsonl codex-repair-stderr.log\n set +e\n npx -y @openai/codex@0 \"${CODEX_ARGS[@]}\" --json \"$(cat recap-repair-prompt.md)\" 2> codex-repair-stderr.log | tee codex-repair-events.jsonl\n CODEX_REPAIR_STATUS=\"${PIPESTATUS[0]}\"\n set -e\n echo \"$CODEX_REPAIR_STATUS\" > codex-repair-exit-code.txt\n if [ \"$CODEX_REPAIR_STATUS\" -eq 0 ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n - name: Repair recap source (OpenAI-compatible)\n id: openai_compatible_repair\n if: steps.publish.outputs.repairable == 'true' && needs.gate.outputs.agent == 'openai-compatible'\n continue-on-error: true\n env:\n OPENAI_API_KEY: ${{ secrets.VISUAL_RECAP_API_KEY }}\n OPENAI_BASE_URL: ${{ vars.VISUAL_RECAP_BASE_URL }}\n AGENT_ENGINE: ai-sdk:openai\n AGENT_MODEL: ${{ vars.VISUAL_RECAP_MODEL }}\n AGENT_NATIVE_CODE_USAGE_FILE: openai-compatible-repair-usage.json\n AGENT_NATIVE_CODE_TOOL_PROFILE: recap-source\n run: |\n set -uo pipefail\n rm -f openai-compatible-repair-result.txt openai-compatible-repair-usage.json openai-compatible-repair-stderr.log\n set +e\n $CODE_CLI code exec --permission-mode auto-edit \"$(cat recap-repair-prompt.md)\" > openai-compatible-repair-result.txt 2> openai-compatible-repair-stderr.log\n OPENAI_COMPATIBLE_REPAIR_STATUS=\"$?\"\n set -e\n echo \"$OPENAI_COMPATIBLE_REPAIR_STATUS\" > openai-compatible-repair-exit-code.txt\n if [ \"$OPENAI_COMPATIBLE_REPAIR_STATUS\" -eq 0 ]; then echo \"ok=true\" >> \"$GITHUB_OUTPUT\"; else echo \"ok=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n - name: Validate repaired recap source\n id: repaired_source\n if: steps.publish.outputs.repairable == 'true'\n env:\n REPAIR_AGENT_OK: ${{ steps.claude_repair.outputs.ok || steps.codex_repair.outputs.ok || steps.openai_compatible_repair.outputs.ok }}\n run: |\n set -uo pipefail\n REPAIR_REASON=\"\"\n if [ \"${REPAIR_AGENT_OK:-false}\" != \"true\" ]; then\n echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\n REPAIR_REASON=\"Repair agent exited unsuccessfully; repaired source was not published.\"\n else\n VALIDATION_JSON=\"$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap validate-repair --original recap-source.initial.json --source recap-source.json --reason-file recap-url-reason.txt || true)\"\n REPAIR_OK=\"$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?\"true\":\"false\")}catch{process.stdout.write(\"false\")}' \"$VALIDATION_JSON\")\"\n REPAIR_REASON=\"$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"Repair validation returned invalid output.\")}' \"$VALIDATION_JSON\")\"\n echo \"ok=$REPAIR_OK\" >> \"$GITHUB_OUTPUT\"\n fi\n if [ -n \"$REPAIR_REASON\" ]; then\n echo \"$REPAIR_REASON\" > recap-url-reason.txt\n fi\n {\n echo 'reason<<__RECAP_REPAIR_REASON_EOF__'\n echo \"$REPAIR_REASON\"\n echo '__RECAP_REPAIR_REASON_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n\n - name: Publish repaired recap source\n id: publish_repair\n if: steps.repaired_source.outputs.ok == 'true'\n continue-on-error: true\n env:\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n run: |\n set -uo pipefail\n ARGS=(--source recap-source.json --out recap-url.txt --repo \"$GITHUB_REPOSITORY\" --pr \"$PR_NUMBER\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\")\n if [ -n \"${PREV_PLAN_ID:-}\" ]; then ARGS+=(--prev-plan-id \"$PREV_PLAN_ID\"); fi\n ARGS+=(--source-type pull-request --source-repo \"$GITHUB_REPOSITORY\" --source-pr-number \"$PR_NUMBER\")\n if [ \"${PR_MERGED:-false}\" = \"true\" ] || [ -n \"${PR_MERGED_AT:-}\" ]; then\n ARGS+=(--source-pr-state merged)\n elif [ -n \"${PR_STATE:-}\" ]; then\n ARGS+=(--source-pr-state \"$PR_STATE\")\n fi\n if [ -n \"${PR_MERGED_AT:-}\" ]; then ARGS+=(--source-pr-merged-at \"$PR_MERGED_AT\"); fi\n $RECAP_CLI recap publish \"${ARGS[@]}\"\n\n - name: Read plan URL\n id: url\n if: steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n run: |\n set -uo pipefail\n PLAN_URL=\"\"\n URL_REASON=\"\"\n if [ -f recap-url.txt ]; then\n PLAN_URL=\"$(tr -d '\\r\\n' < recap-url.txt | tr -d ' ')\"\n elif [ -f recap-url-reason.txt ]; then\n URL_REASON=\"$(cat recap-url-reason.txt)\"\n else\n URL_REASON=\"recap-url.txt was not created.\"\n fi\n # recap-url.txt is agent-written -> untrusted. Rebuild a canonical\n # recap URL from the trusted app base and a strictly validated plan id,\n # preserving path-prefixed self-hosted mounts.\n if [ -z \"$URL_REASON\" ]; then\n URL_RESULT=$(PLAN_URL=\"$PLAN_URL\" node <<'NODE'\n const emit = (value) => process.stdout.write(JSON.stringify(value));\n try {\n const raw = process.env.PLAN_URL || \"\";\n if (!raw) {\n emit({ url: \"\", reason: \"recap-url.txt was empty\" });\n process.exit(0);\n }\n const trusted = new URL(process.env.PLAN_RECAP_APP_URL || \"https://plan.agent-native.com\");\n const parsed = /^https?:\\/\\//i.test(raw)\n ? new URL(raw)\n : new URL(raw, trusted);\n if (parsed.origin !== trusted.origin) {\n emit({ url: \"\", reason: `recap-url.txt points at ${parsed.origin}, expected ${trusted.origin}` });\n process.exit(0);\n }\n\n const base = trusted.pathname.replace(/\\/$/, \"\");\n const paths = [parsed.pathname];\n if (base && parsed.pathname.startsWith(`${base}/`)) {\n paths.push(parsed.pathname.slice(base.length) || \"/\");\n }\n\n for (const path of paths) {\n const match = path.match(/^\\/(?:plans|recaps)\\/([A-Za-z0-9_-]+)\\/?$/);\n if (match) {\n emit({ url: `${trusted.origin}${base}/recaps/${match[1]}`, reason: \"\" });\n process.exit(0);\n }\n }\n emit({ url: \"\", reason: \"recap-url.txt did not contain a valid /plans/<id> or /recaps/<id> URL for the configured plan app\" });\n } catch {\n emit({ url: \"\", reason: \"recap-url.txt was not a valid URL or recap path\" });\n }\n NODE\n )\n CANONICAL_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).url||\"\")}catch{process.stdout.write(\"\")}' \"$URL_RESULT\")\n URL_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"recap-url.txt URL validation failed\")}' \"$URL_RESULT\")\n else\n CANONICAL_URL=\"\"\n fi\n if [ -n \"$CANONICAL_URL\" ]; then\n echo \"plan_url=$CANONICAL_URL\" >> \"$GITHUB_OUTPUT\"; echo \"ok=true\" >> \"$GITHUB_OUTPUT\"\n else\n echo \"plan_url=\" >> \"$GITHUB_OUTPUT\"; echo \"ok=false\" >> \"$GITHUB_OUTPUT\"\n fi\n {\n echo 'reason<<__RECAP_URL_REASON_EOF__'\n echo \"$URL_REASON\"\n echo '__RECAP_URL_REASON_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n\n - name: Summarize agent failure\n id: agent_summary\n if: steps.url.outputs.ok != 'true' && steps.diff.outputs.tiny != 'true' && steps.route_health.outputs.unhealthy != 'true' && steps.scan.outputs.suppressed != 'true'\n continue-on-error: true\n env:\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_ATTEMPTED: ${{ steps.repair_prompt.outcome == 'success' }}\n RECAP_BLOCK_REFERENCE_SUMMARY: ${{ steps.block_reference.outputs.summary }}\n RECAP_PUBLISH_REASON: ${{ steps.repaired_source.outputs.reason || steps.publish_repair.outputs.reason || steps.publish.outputs.reason }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n STDERR=claude-stderr.log\n EXIT_CODE=claude-exit-code.txt\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then\n RESULT=codex-events.jsonl\n STDERR=codex-stderr.log\n EXIT_CODE=codex-exit-code.txt\n elif [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then\n RESULT=openai-compatible-result.txt\n STDERR=openai-compatible-stderr.log\n EXIT_CODE=openai-compatible-exit-code.txt\n fi\n if [ \"$RECAP_REPAIR_ATTEMPTED\" = \"true\" ]; then\n RESULT=claude-repair-result.json\n STDERR=claude-repair-stderr.log\n EXIT_CODE=claude-repair-exit-code.txt\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then\n RESULT=codex-repair-events.jsonl\n STDERR=codex-repair-stderr.log\n EXIT_CODE=codex-repair-exit-code.txt\n elif [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then\n RESULT=openai-compatible-repair-result.txt\n STDERR=openai-compatible-repair-stderr.log\n EXIT_CODE=openai-compatible-repair-exit-code.txt\n fi\n fi\n SUMMARY_JSON=\"$(GITHUB_OUTPUT=/dev/null $RECAP_CLI recap agent-summary --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --stderr-file \"$STDERR\" --exit-code-file \"$EXIT_CODE\" || echo '{}')\"\n SUMMARY=\"$(node -e 'try { const value = JSON.parse(process.argv[1]).summary; process.stdout.write(typeof value === \"string\" ? value : \"\"); } catch {}' \"$SUMMARY_JSON\")\"\n if [ -n \"$SUMMARY\" ]; then\n {\n echo 'summary<<__RECAP_AGENT_SUMMARY_EOF__'\n echo \"$SUMMARY\"\n echo '__RECAP_AGENT_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n elif [ -n \"${RECAP_BLOCK_REFERENCE_SUMMARY:-}\" ]; then\n {\n echo 'summary<<__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'\n echo \"$RECAP_BLOCK_REFERENCE_SUMMARY\"\n echo '__RECAP_BLOCK_REFERENCE_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n elif [ -n \"${RECAP_PUBLISH_REASON:-}\" ]; then\n {\n echo 'summary<<__RECAP_PUBLISH_SUMMARY_EOF__'\n echo \"$RECAP_PUBLISH_REASON\"\n echo '__RECAP_PUBLISH_SUMMARY_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n fi\n\n - name: Attach usage\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n # Use the gate-normalized agent so \"Codex\" still selects the right file.\n RECAP_AGENT: ${{ needs.gate.outputs.agent }}\n RECAP_REPAIR_SUCCEEDED: ${{ steps.publish_repair.outcome == 'success' }}\n run: |\n set -uo pipefail\n RESULT=claude-result.json\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-events.jsonl; fi\n if [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then RESULT=openai-compatible-usage.json; fi\n if [ \"$RECAP_REPAIR_SUCCEEDED\" = \"true\" ]; then\n RESULT=claude-repair-result.json\n if [ \"$RECAP_AGENT\" = \"codex\" ]; then RESULT=codex-repair-events.jsonl; fi\n if [ \"$RECAP_AGENT\" = \"openai-compatible\" ]; then RESULT=openai-compatible-repair-usage.json; fi\n fi\n if [ -f \"$RESULT\" ]; then $RECAP_CLI recap usage --plan-url \"$PLAN_URL\" --agent \"$RECAP_AGENT\" --result-file \"$RESULT\" --model \"${VISUAL_RECAP_MODEL:-}\" --app-url \"$PLAN_RECAP_APP_URL\" --token \"$PLAN_RECAP_TOKEN\" || true; fi\n\n - name: Cache Playwright browsers\n if: steps.url.outputs.ok == 'true'\n uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3\n with:\n path: ~/.cache/ms-playwright\n key: playwright-1-${{ runner.os }}\n\n - name: Screenshot + upload\n id: shot\n if: steps.url.outputs.ok == 'true'\n continue-on-error: true\n env:\n # recap-url.txt is untrusted agent output; pass via env, never ${{ }}.\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n run: |\n set -uo pipefail\n if [ -n \"${RECAP_PLAYWRIGHT:-}\" ] && [ -x \"$RECAP_PLAYWRIGHT\" ]; then\n \"$RECAP_PLAYWRIGHT\" install --with-deps chromium || true\n elif command -v pnpm >/dev/null 2>&1; then\n pnpm exec playwright install --with-deps chromium 2>/dev/null || npx -y playwright@1 install --with-deps chromium || true\n else\n npx -y playwright@1 install --with-deps chromium || true\n fi\n IMAGE_CACHE_KEY=\"$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT\"\n LIGHT_SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap.png --theme light --image-cache-key \"$IMAGE_CACHE_KEY\" || echo '{}')\"\n DARK_SHOT_JSON=\"$($RECAP_CLI recap shot --url \"$PLAN_URL\" --token \"$PLAN_RECAP_TOKEN\" --app-url \"$PLAN_RECAP_APP_URL\" --out recap-dark.png --theme dark --image-cache-key \"$IMAGE_CACHE_KEY\" || echo '{}')\"\n for SHOT_LABEL in light dark; do\n if [ \"$SHOT_LABEL\" = \"light\" ]; then SHOT_JSON=\"$LIGHT_SHOT_JSON\"; else SHOT_JSON=\"$DARK_SHOT_JSON\"; fi\n SHOT_LABEL=\"$SHOT_LABEL\" SHOT_JSON=\"$SHOT_JSON\" node -e 'const label = process.env.SHOT_LABEL || \"shot\"; let parsed = {}; try { parsed = JSON.parse(process.env.SHOT_JSON || \"{}\"); } catch { parsed = { ok: false, reason: \"invalid shot JSON\" }; } const summary = { ok: parsed.ok === true, imageUrl: parsed.imageUrl ? \"[present]\" : \"\", out: typeof parsed.out === \"string\" ? parsed.out : \"\", reason: typeof parsed.reason === \"string\" ? parsed.reason.slice(0, 500) : \"\" }; console.log(`[recap shot] ${label}: ${JSON.stringify(summary)}`);'\n done\n IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$LIGHT_SHOT_JSON\")\n DARK_IMAGE_URL=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).imageUrl||\"\")}catch{process.stdout.write(\"\")}' \"$DARK_SHOT_JSON\")\n SHOT_STATUS=$(LIGHT_SHOT_JSON=\"$LIGHT_SHOT_JSON\" DARK_SHOT_JSON=\"$DARK_SHOT_JSON\" node <<'NODE'\n const parse = (raw) => { try { return JSON.parse(raw || \"{}\"); } catch { return { ok: false, reason: \"invalid shot JSON\" }; } };\n const shots = [[\"light\", parse(process.env.LIGHT_SHOT_JSON)], [\"dark\", parse(process.env.DARK_SHOT_JSON)]];\n const hasImage = shots.some(([, shot]) => typeof shot.imageUrl === \"string\" && shot.imageUrl.trim());\n const reasons = shots.flatMap(([label, shot]) => {\n if (typeof shot.reason === \"string\" && shot.reason.trim()) return [`${label}: ${shot.reason.trim()}`];\n if (!(typeof shot.imageUrl === \"string\" && shot.imageUrl.trim())) return [`${label}: no imageUrl returned`];\n return [];\n });\n process.stdout.write(JSON.stringify({ ok: hasImage, reason: hasImage ? \"\" : reasons.join(\"; \").slice(0, 1000) }));\n NODE\n )\n SHOT_OK=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).ok===true?\"true\":\"false\")}catch{process.stdout.write(\"false\")}' \"$SHOT_STATUS\")\n SHOT_REASON=$(node -e 'try{process.stdout.write(JSON.parse(process.argv[1]).reason||\"\")}catch{process.stdout.write(\"invalid shot status JSON\")}' \"$SHOT_STATUS\")\n if [ \"$SHOT_OK\" != \"true\" ]; then\n echo \"::warning::Visual recap screenshot unavailable; posting screenshot-failed recap comment. $SHOT_REASON\"\n fi\n echo \"image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"light_image_url=$IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"dark_image_url=$DARK_IMAGE_URL\" >> \"$GITHUB_OUTPUT\"\n echo \"shot_ok=$SHOT_OK\" >> \"$GITHUB_OUTPUT\"\n {\n echo 'shot_reason<<__RECAP_SHOT_REASON_EOF__'\n echo \"$SHOT_REASON\"\n echo '__RECAP_SHOT_REASON_EOF__'\n } >> \"$GITHUB_OUTPUT\"\n if [ -f recap.png ] || [ -f recap-dark.png ]; then echo \"captured=true\" >> \"$GITHUB_OUTPUT\"; else echo \"captured=false\" >> \"$GITHUB_OUTPUT\"; fi\n\n - name: Upload recap screenshot artifact\n if: steps.shot.outputs.captured == 'true'\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n name: pr-visual-recap-${{ github.event.pull_request.number }}\n path: |\n recap.png\n recap-dark.png\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upload recap source artifact\n if: always() && !cancelled()\n uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1\n with:\n # recap-source.json + the agent transcript (claude-result.json /\n # codex-events.jsonl + stderr) are the only window into WHAT the agent\n # did when a publish fails (no plan URL) \u2014 INCLUDING the case where it\n # finished without writing recap-source.json at all. The sticky comment\n # only shows the screenshot, so without these a failed recap is\n # undebuggable. Uploaded on success + failure; tolerant when absent.\n name: pr-visual-recap-source-${{ github.event.pull_request.number }}\n path: |\n recap-source.json\n recap-source.initial.json\n recap-url-reason.txt\n recap-repair-prompt.md\n claude-result.json\n claude-stderr.log\n claude-repair-result.json\n claude-repair-stderr.log\n claude-repair-exit-code.txt\n codex-events.jsonl\n codex-stderr.log\n codex-repair-events.jsonl\n codex-repair-stderr.log\n codex-repair-exit-code.txt\n openai-compatible-result.txt\n openai-compatible-usage.json\n openai-compatible-stderr.log\n openai-compatible-repair-result.txt\n openai-compatible-repair-usage.json\n openai-compatible-repair-stderr.log\n openai-compatible-repair-exit-code.txt\n if-no-files-found: ignore\n retention-days: 14\n\n - name: Upsert sticky comment\n if: always() && !cancelled()\n continue-on-error: true\n env:\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n RECAP_IMAGE_URL: ${{ steps.shot.outputs.image_url }}\n RECAP_LIGHT_IMAGE_URL: ${{ steps.shot.outputs.light_image_url }}\n RECAP_DARK_IMAGE_URL: ${{ steps.shot.outputs.dark_image_url }}\n RECAP_SHOT_OK: ${{ steps.shot.outputs.shot_ok }}\n RECAP_SHOT_REASON: ${{ steps.shot.outputs.shot_reason }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n PREV_PLAN_ID: ${{ steps.prev.outputs.plan_id }}\n RECAP_AUTH_FAILED: ${{ steps.auth_probe.outputs.auth_failed }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n # Prefer the route-health diagnostic when the plan app routes are not\n # yet deployed so the comment explains the 404 instead of a generic\n # \"recap-url.txt was not created\" message.\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -euo pipefail\n $RECAP_CLI recap comment upsert --repo \"$GITHUB_REPOSITORY\" --issue \"$PR_NUMBER\" --token \"$GH_TOKEN\" --head-sha \"$HEAD_SHA\"\n\n - name: Complete visual recap check\n if: always() && !cancelled() && steps.recap_check.outputs.check_run_id != ''\n continue-on-error: true\n env:\n # Untrusted/step values via env (NOT ${{ }}-interpolated into the run\n # body): the agent-written plan URL and the scan JSON could inject shell.\n CHECK_RUN_ID: ${{ steps.recap_check.outputs.check_run_id }}\n PLAN_OK: ${{ steps.url.outputs.ok }}\n PLAN_URL: ${{ steps.url.outputs.plan_url }}\n SUPPRESSED: ${{ steps.scan.outputs.suppressed }}\n SUPPRESSED_JSON: ${{ steps.scan.outputs.json }}\n DIFF_HUGE: ${{ steps.diff.outputs.huge }}\n DIFF_TINY: ${{ steps.diff.outputs.tiny }}\n RECAP_AGENT_SUMMARY: ${{ steps.agent_summary.outputs.summary }}\n RECAP_URL_REASON: ${{ steps.route_health.outputs.reason || steps.url.outputs.reason }}\n run: |\n set -uo pipefail\n $RECAP_CLI recap check complete \\\n --check-run-id \"$CHECK_RUN_ID\" \\\n --plan-ok \"$PLAN_OK\" \\\n --plan-url \"$PLAN_URL\" \\\n --suppressed \"$SUPPRESSED\" \\\n --suppressed-json \"$SUPPRESSED_JSON\" \\\n --huge \"$DIFF_HUGE\" \\\n --tiny \"$DIFF_TINY\" \\\n --failure-summary \"$RECAP_AGENT_SUMMARY\" \\\n --url-reason \"$RECAP_URL_REASON\" \\\n --workflow-url \"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID\"\n";
3
3
  //# sourceMappingURL=pr-visual-recap-workflow.d.ts.map