Start the auto-spawned terminal in the departing pane's cwd #5437
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Generated by tend 0.2.10. Regenerate with: uvx tend@latest init | |
| # | |
| # Do not edit this file directly — it will be overwritten on regeneration. | |
| # To customize behavior, edit the relevant skill (for example, | |
| # `running-tend`) in this repo's .claude/skills/ directory, or open an issue at | |
| # https://github.com/max-sixty/tend/issues for changes that need to | |
| # happen upstream in the tend-ci-runner plugin. | |
| name: tend-mention | |
| on: | |
| issues: | |
| types: [edited] | |
| issue_comment: | |
| types: [created, edited] | |
| # Review events arrive re-posted by tend-mention-relay. GitHub creates this | |
| # run even though a `GITHUB_TOKEN` triggered it — `workflow_dispatch` and | |
| # `repository_dispatch` are the two events exempt from the rule that | |
| # token-triggered events start no workflow — and it carries the default | |
| # branch, which the environment admits. | |
| repository_dispatch: | |
| types: [tend-mention-review] | |
| jobs: | |
| verify: | |
| # Skip comments on the issues tend files about its own health: the action | |
| # auto-comments on those when a run fails or is refused, and without this | |
| # guard those comments re-trigger tend-mention, producing a | |
| # self-sustaining ~1 run/minute loop until the underlying condition | |
| # clears. The prompt's self-loop guard can't help here because the model | |
| # never executes — the action fails before Claude starts. A relayed review | |
| # enters as `repository_dispatch` and is judged in the check step below, | |
| # against the record the API holds. | |
| if: | | |
| vars.TEND_ENABLED != 'false' && ( | |
| github.event_name == 'repository_dispatch' || | |
| (github.event_name == 'issues' && | |
| contains(github.event.issue.body, '@dormouse-bot')) || | |
| (github.event_name == 'issue_comment' && | |
| contains(github.event.issue.labels.*.name, 'tend-outage') == false && contains(github.event.issue.labels.*.name, 'tend-rate-limit') == false)) | |
| runs-on: ubuntu-24.04 | |
| environment: | |
| name: tend | |
| deployment: false | |
| permissions: | |
| contents: read | |
| outputs: | |
| should_run: ${{ steps.check.outputs.should_run }} | |
| reason: ${{ steps.check.outputs.reason }} | |
| url: ${{ steps.check.outputs.url }} | |
| ts: ${{ steps.check.outputs.ts }} | |
| steps: | |
| - uses: astral-sh/setup-uv@v10.1.0 | |
| id: tend_uv | |
| env: | |
| UV_NO_MODIFY_PATH: "1" | |
| with: | |
| version: "0.12.13" | |
| enable-cache: false | |
| ignore-empty-workdir: true | |
| - name: Verify bot engagement | |
| id: check | |
| run: | | |
| "$TEND_UV" run --script - <<'TEND_PY' | |
| # /// script | |
| # requires-python = ">=3.12" | |
| # dependencies = [] | |
| # /// | |
| """Decide whether a mention event should start an agent session.""" | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| def gh(*args: str, quiet: bool = False) -> str: | |
| # `gh` colorizes a piped `--json`/`--jq` response when the job's | |
| # environment forces color, and the ANSI codes land inside the body | |
| # `gh_json` parses. `CLICOLOR_FORCE=0` is the setting that defeats it: | |
| # `gh` ranks a forced value above `NO_COLOR`, so `NO_COLOR` alone loses. | |
| env = os.environ.copy() | |
| env.update(NO_COLOR="1", CLICOLOR_FORCE="0") | |
| result = subprocess.run( | |
| ["gh", *args], capture_output=True, text=True, env=env, check=False | |
| ) | |
| if result.returncode: | |
| if result.stderr and not quiet: | |
| sys.stderr.write(result.stderr) | |
| raise subprocess.CalledProcessError( | |
| result.returncode, result.args, result.stdout, result.stderr | |
| ) | |
| return result.stdout | |
| def gh_json(*args: str, quiet: bool = False) -> Any: | |
| return json.loads(gh(*args, quiet=quiet)) | |
| def gh_paginated(path: str) -> list[dict[str, Any]]: | |
| text = gh("api", "--paginate", path) | |
| decoder = json.JSONDecoder() | |
| position = 0 | |
| items: list[dict[str, Any]] = [] | |
| while position < len(text): | |
| while position < len(text) and text[position].isspace(): | |
| position += 1 | |
| if position == len(text): | |
| break | |
| page, position = decoder.raw_decode(text, position) | |
| if not isinstance(page, list): | |
| raise TypeError("paginated GitHub response was not an array") | |
| items.extend(page) | |
| return items | |
| def actor_login(actor: object) -> str: | |
| """Return a GitHub actor login, including for deleted-account records.""" | |
| if not isinstance(actor, dict): | |
| return "" | |
| return str(actor.get("login") or "") | |
| def output(name: str, value: str | bool) -> None: | |
| rendered = str(value).lower() if isinstance(value, bool) else value | |
| with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream: | |
| stream.write(f"{name}={rendered}\n") | |
| def verdict(should_run: bool, reason: str = "") -> int: | |
| output("should_run", should_run) | |
| if reason: | |
| output("reason", reason) | |
| return 0 | |
| def main() -> int: | |
| env = os.environ | |
| bot = env.get("BOT_NAME", "") | |
| repo = env.get("GITHUB_REPOSITORY", "") | |
| kind = env.get("EVENT_NAME", "") | |
| comment_body = env.get("COMMENT_BODY", "") | |
| comment_author = env.get("COMMENT_AUTHOR", "") | |
| review_author = "" | |
| review_state = "" | |
| inline: list[dict[str, Any]] = [] | |
| if kind == "repository_dispatch": | |
| kind = env.get("PAYLOAD_KIND", "") | |
| pr = env.get("PAYLOAD_PR", "") | |
| item_id = env.get("PAYLOAD_ID", "") | |
| if not pr.isdigit() or not item_id.isdigit(): | |
| print("malformed dispatch payload — skipping") | |
| return verdict(False) | |
| if kind == "pull_request_review": | |
| try: | |
| review = gh_json( | |
| "api", f"repos/{repo}/pulls/{pr}/reviews/{item_id}", quiet=True | |
| ) | |
| except (subprocess.CalledProcessError, json.JSONDecodeError): | |
| print(f"review {item_id} not found on PR {pr} — skipping") | |
| return verdict(False) | |
| review_author = actor_login(review.get("user")) | |
| review_state = str(review["state"]).lower() | |
| comment_body = review.get("body") or "" | |
| output("url", review["html_url"]) | |
| output("ts", review.get("submitted_at") or "") | |
| elif kind == "pull_request_review_comment": | |
| try: | |
| comment = gh_json( | |
| "api", f"repos/{repo}/pulls/comments/{item_id}", quiet=True | |
| ) | |
| except (subprocess.CalledProcessError, json.JSONDecodeError): | |
| print(f"comment {item_id} not found — skipping") | |
| return verdict(False) | |
| expected_pr = f"https://api.github.com/repos/{repo}/pulls/{pr}" | |
| if comment.get("pull_request_url") != expected_pr: | |
| print(f"comment {item_id} does not belong to PR {pr} — skipping") | |
| return verdict(False) | |
| comment_author = actor_login(comment.get("user")) | |
| comment_body = comment.get("body") or "" | |
| output("url", comment["html_url"]) | |
| output("ts", comment["updated_at"]) | |
| else: | |
| print(f"unknown dispatch kind '{kind}' — skipping") | |
| return verdict(False) | |
| if kind == "issues": | |
| return verdict(True) | |
| if ( | |
| kind in {"issue_comment", "pull_request_review_comment"} | |
| and comment_author == bot | |
| ): | |
| return verdict(False) | |
| if comment_body and f"@{bot}" in comment_body: | |
| return verdict(True, "mention") | |
| if kind == "issue_comment" and env.get("COMMENT_AUTHOR_TYPE") == "Bot": | |
| return verdict(False) | |
| if kind == "pull_request_review": | |
| inline = gh_paginated( | |
| f"repos/{repo}/pulls/{env.get('PAYLOAD_PR', '')}/reviews/" | |
| f"{env.get('PAYLOAD_ID', '')}/comments" | |
| ) | |
| if any(f"@{bot}" in (comment.get("body") or "") for comment in inline): | |
| return verdict(True, "mention") | |
| # A review the bot wrote hands work to nobody: the review session | |
| # applies the findings it raised. The mention checks run first, so | |
| # naming the bot in a review still summons a session. | |
| if review_author == bot: | |
| return verdict(False) | |
| if review_state == "approved" and not comment_body and not inline: | |
| return verdict(False) | |
| if kind == "issue_comment": | |
| issue_number = env.get("ISSUE_OR_PR_NUMBER", "") | |
| if not env.get("PR_URL"): | |
| if env.get("ISSUE_AUTHOR") == bot or f"@{bot}" in env.get("ISSUE_BODY", ""): | |
| return verdict(True) | |
| comments = gh_paginated(f"repos/{repo}/issues/{issue_number}/comments") | |
| return verdict( | |
| any(actor_login(comment.get("user")) == bot for comment in comments) | |
| ) | |
| pr_number = issue_number | |
| else: | |
| pr_number = env.get("PAYLOAD_PR", "") | |
| pr = gh_json("pr", "view", pr_number, "--repo", repo, "--json", "author") | |
| pr_author = actor_login(pr.get("author")) | |
| if pr_author == bot: | |
| return verdict(True, "participation") | |
| reviews = gh_paginated(f"repos/{repo}/pulls/{pr_number}/reviews") | |
| if any(actor_login(review.get("user")) == bot for review in reviews): | |
| return verdict(True, "participation") | |
| comments = gh_paginated(f"repos/{repo}/issues/{pr_number}/comments") | |
| if any(actor_login(comment.get("user")) == bot for comment in comments): | |
| return verdict(True, "participation") | |
| return verdict(False) | |
| if __name__ == "__main__": | |
| try: | |
| raise SystemExit(main()) | |
| except subprocess.CalledProcessError as error: | |
| raise SystemExit(error.returncode or 1) from None | |
| TEND_PY | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| TEND_UV: ${{ steps.tend_uv.outputs.uv-path }} | |
| BOT_NAME: dormouse-bot | |
| EVENT_NAME: ${{ github.event_name }} | |
| COMMENT_BODY: ${{ github.event.comment.body }} | |
| COMMENT_AUTHOR: ${{ github.event.comment.user.login }} | |
| COMMENT_AUTHOR_TYPE: ${{ github.event.comment.user.type }} | |
| ISSUE_BODY: ${{ github.event.issue.body }} | |
| ISSUE_OR_PR_NUMBER: ${{ github.event.issue.number }} | |
| ISSUE_AUTHOR: ${{ github.event.issue.user.login }} | |
| PR_URL: ${{ github.event.issue.pull_request.url }} | |
| PAYLOAD_KIND: ${{ github.event.client_payload.kind }} | |
| PAYLOAD_PR: ${{ github.event.client_payload.pr }} | |
| PAYLOAD_ID: ${{ github.event.client_payload.id }} | |
| handle: | |
| needs: verify | |
| if: needs.verify.outputs.should_run == 'true' | |
| concurrency: | |
| group: ${{ github.workflow }}-handle-${{ github.event.issue.number || github.event.client_payload.pr }} | |
| cancel-in-progress: false | |
| runs-on: ubuntu-24.04 | |
| environment: | |
| name: tend | |
| deployment: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: read | |
| issues: write | |
| steps: | |
| # Both halves of the reaction belong to this job, so the eyes can only | |
| # go on once the job that takes them off has started. Put them in | |
| # `verify` and `handle` respectively and the routine burst case strands | |
| # them: a third mention on one thread evicts the second's pending | |
| # `handle` — a job cancelled while queued allocates no runner and runs | |
| # no steps, `always()` included — while its `verify` already reacted. | |
| # | |
| # The dispatch arm covers a relayed inline comment, whose id the check | |
| # step verified belongs to this PR; a review *submission* has no single | |
| # comment to react to, so it gets no eyes. The job's own `if` already | |
| # carries `should_run`. | |
| - name: React with eyes | |
| if: | | |
| ((github.event.comment && contains(github.event.comment.body, '@dormouse-bot')) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' | |
| && needs.verify.outputs.reason == 'mention')) | |
| run: | | |
| gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \ | |
| || echo "::warning::could not add the eyes reaction" | |
| env: | |
| REPO: ${{ github.repository }} | |
| TARGET: ${{ github.event_name == 'issue_comment' | |
| && format('issues/comments/{0}', github.event.comment.id) | |
| || format('pulls/comments/{0}', github.event.client_payload.id) }} | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} | |
| - uses: actions/checkout@v7 | |
| with: | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| token: ${{ secrets.TEND_BOT_TOKEN }} | |
| - name: Compute queue delay | |
| id: delay | |
| run: | | |
| if [ -z "$EVENT_TS" ]; then | |
| echo "seconds=" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| event_epoch=$(date -d "$EVENT_TS" +%s) | |
| echo "seconds=$(( $(date +%s) - event_epoch ))" >> "$GITHUB_OUTPUT" | |
| env: | |
| # A relayed event's timestamp comes from verify, which read it off | |
| # the API record — the dispatch payload never carries one to spoof. | |
| EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }} | |
| - uses: max-sixty/tend/claude@0.2.10 | |
| with: | |
| github_token: ${{ secrets.TEND_BOT_TOKEN }} | |
| claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} | |
| anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| bot_name: dormouse-bot | |
| model: opus | |
| checkout_mode: mention | |
| base_branch: ${{ github.event.repository.default_branch }} | |
| prompt: >- | |
| ${{ steps.delay.outputs.seconds | |
| && format('This job started {0}s after the triggering event (over ~40s means it was queued). ', | |
| steps.delay.outputs.seconds) || '' }}Before acting, | |
| check recent comments: exit silently if the bot already responded | |
| to the trigger; handle any other unaddressed comments too. | |
| ${{ github.event_name == 'issues' | |
| && format('An issue was updated with a mention of you ({0}). Read it and respond.', github.event.issue.html_url) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' && needs.verify.outputs.reason == 'mention' | |
| && format('You were mentioned in an inline review comment on PR #{0} ({1}, comment ID {2}). Read the full context, then respond. If changes are requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' | |
| && format('An inline review comment was posted on a PR where you previously participated (PR #{0}, {1}, comment ID {2}). Read the full context. Only respond if the comment is directed at you or requests changes.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) | |
| || (github.event.client_payload.kind == 'pull_request_review' && needs.verify.outputs.reason == 'mention' | |
| && format('A review was submitted on PR #{0} that mentions you ({1}, review ID {2}). Read the review and full context, then respond. If changes were requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) | |
| || (github.event.client_payload.kind == 'pull_request_review' | |
| && format('A review was submitted on a PR where you previously participated (PR #{0}, {1}, review ID {2}). Read the review and full context. If it requests changes or asks questions, respond appropriately. Exit silently for a plain approval, a review with no actionable content, or one between other participants.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id)) | |
| || (contains(github.event.comment.body, '@dormouse-bot') | |
| && format('You were mentioned in a comment ({0}). Read the full context and respond. If changes are requested, make them, commit, and push.', github.event.comment.html_url)) | |
| || format('A user commented on an issue/PR where you previously participated ({0}). Read the full context. Only respond if the comment is directed at you, asks a question you can help with, or requests changes you can make. If the conversation is between other participants, exit silently.', github.event.comment.html_url) | |
| }} | |
| - name: Remove the eyes reaction | |
| if: | | |
| always() | |
| && ((github.event.comment && contains(github.event.comment.body, '@dormouse-bot')) | |
| || (github.event.client_payload.kind == 'pull_request_review_comment' | |
| && needs.verify.outputs.reason == 'mention')) | |
| run: | | |
| REACTION_ID=$(gh api --paginate \ | |
| "repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \ | |
| --jq ".[] | select(.user.login == \"$BOT_NAME\") | .id" | head -n1) | |
| if [ -n "$REACTION_ID" ]; then | |
| gh api -X DELETE "repos/$REPO/$TARGET/reactions/$REACTION_ID" --silent \ | |
| || echo "::warning::could not remove the eyes reaction" | |
| fi | |
| env: | |
| REPO: ${{ github.repository }} | |
| TARGET: ${{ github.event_name == 'issue_comment' | |
| && format('issues/comments/{0}', github.event.comment.id) | |
| || format('pulls/comments/{0}', github.event.client_payload.id) }} | |
| BOT_NAME: dormouse-bot | |
| GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }} |