Agent Presence Skill
Shared protocol for answering: "who is working on this repository right now, what are they doing, and when may another agent take over?"
This skill is deliberately tool-agnostic. Claude Code, Codex CLI, Gemini CLI,
and future workers use this as the common contract. Each CLI still needs a
thin adapter (see dispatch-watcher-claude, dispatch-watcher-gemini,
dispatch-watcher) for loading the skill, posting Slack messages, and wiring
shutdown hooks.
Contract
- Input:
AGENT_ID, access to the project's dispatch channel (<DISPATCH_CHANNEL>), and GitHub access throughgh(read for pre-flight, write for claims/updates). - Output: a pre-flight status decision before starting work, plus optional presence/claim updates.
- Durable source of truth: GitHub issue comments, open PRs, branches, commits, and labels.
- Cache / operator view: pinned Slack roster message. The roster is never a lock and may be stale.
- Scope: coding agents only. PM automation may reuse the same protocol but has additional heartbeat/leader-election rules — out of scope here.
Identity
Every agent session must set:
AGENT_ID="<model>-<machine>-<environment>-<seq>"
# examples:
# codex-lenovo-wsl-1
# claude-mbp-macos-1
# gemini-hpm01-ubuntu-1
environment is one of wsl|windows|ubuntu|macos|cloud. Pick the most specific
that uniquely describes the runtime — wsl is preferred over ubuntu when the
agent runs inside WSL2.
Treat AGENT_ID as an opaque registry key. Do not infer authorization from the
string. If tooling needs display parsing, use:
- first token = model
- last token = sequence number
- second-last token = environment
- all middle tokens joined with
-= machine
Required Pre-Flight
Run this before the first dispatch, claim, or substantive work action in a coding task, including before proposing a PR or claiming an issue. Minimal local status reads required by this pre-flight are allowed.
Verify local state:
git branch --show-current git status --shortDo not overwrite unrelated user or peer-agent changes.
Read the pinned roster message from
<DISPATCH_CHANNEL>, if present. It is a fast cache only.Bind GitHub CLI to the current agent's bot token before every canonical presence query. Adapters may export
GH_TOKENonce or prefix each command, but the shared pre-flight must never depend on the operator's ambientgh authsession:: "${GH_TOKEN:?Set GH_TOKEN to this agent's bot token before GitHub presence queries}"Cross-check GitHub, which is the truth layer. The list commands below are quick discovery helpers only; ownership decisions must not depend on their capped result set.
GH_TOKEN="${GH_TOKEN:?}" gh issue list -R <OWNER>/<REPO> \ --state open \ --limit 200 \ --json number,title,labels,assignees,updatedAt GH_TOKEN="${GH_TOKEN:?}" gh pr list -R <OWNER>/<REPO> \ --state open \ --limit 200 \ --json number,title,headRefName,headRefOid,author,updatedAt,isDraftFor a concrete dispatch target, always fetch that issue's comments directly even when labels are missing. For general pre-flight/takeover reconciliation, prefer a GitHub Search path that returns only open issues whose comments contain
<!-- claim:/<!-- takeover:markers; do not scan every open issue unless search is unavailable or the search result cap is reached. GitHub search is capped at 1000 results; if either marker search returns exactly 1000 issue numbers, treat the result as truncated and fall back to a paginated open-issue scan before reconciling ownership. Labels, assignees, titles, and rostercurrent_taskvalues can prioritize candidates, but they are not the source of truth. Inspect each candidate for active claim and takeover markers:{ GH_TOKEN="${GH_TOKEN:?}" gh search issues \ '"<!-- claim:" in:comments repo:<OWNER>/<REPO> state:open' \ --limit 1000 --json number --jq '.[].number' GH_TOKEN="${GH_TOKEN:?}" gh search issues \ '"<!-- takeover:" in:comments repo:<OWNER>/<REPO> state:open' \ --limit 1000 --json number --jq '.[].number' } | sort -u | while read -r issue_num; do # gh forbids --slurp together with --jq, and --paginate --jq applies the # filter per page (so cross-page sort_by would be wrong). Pipe the raw # per-page arrays to `jq -s 'add | ...'` to merge then filter+sort once. GH_TOKEN="${GH_TOKEN:?}" gh api --paginate \ "repos/<OWNER>/<REPO>/issues/${issue_num}/comments" \ | jq -s 'add | map(select(.body | test("^\\s*<!-- (claim|takeover):")) | {id, created_at, updated_at, body}) | sort_by(.created_at, .id)' donePer-issue canonical marker read:
GH_TOKEN="${GH_TOKEN:?}" gh api --paginate \ "repos/<OWNER>/<REPO>/issues/<num>/comments" \ | jq -s 'add | map(select(.body | test("^\\s*<!-- (claim|takeover):")) | {id, created_at, updated_at, body}) | sort_by(.created_at, .id)'Verify local worktree ownership. A branch checkout changes
HEADfor every agent using the same filesystem path. Before any checkout, merge, rebase, commit, generated-file refresh, or cleanup, confirm no active peer-agent claim/roster entry is using this checkout. If overlap is possible, create a separategit worktreefor your task.Decide:
- If another agent has an active lease or fresh PR/commit activity in the same area, do not start. Post an acknowledgement or pick a disjoint task.
- If the roster says an agent is busy but GitHub shows no active work and
the lease is expired, mark the roster entry
last_event: "unknown"with astale_reasonbefore continuing. - If there is no overlap, emit
startor update your roster entry and begin.
Presence Events
Presence communicates availability to humans and peer agents. It does not grant ownership of a task.
| Event | Meaning |
|---|---|
start |
Session is online and has completed pre-flight. |
busy |
Session is actively working on a task or draft. |
ready |
Session is idle and may accept work. |
standby |
Session is idle and may exit soon. |
pause |
Session is temporarily unavailable but may resume. |
end |
Session is closing cleanly. |
unknown |
Last known session is no longer trustworthy; explain in stale_reason. |
Top-level fallback message format:
<emoji> [<AGENT_ID>] <event> [context]
Examples:
:large_green_circle: [codex-lenovo-wsl-1] start
:large_yellow_circle: [codex-lenovo-wsl-1] busy #2186
:large_green_circle: [codex-lenovo-wsl-1] ready (PR #2190 done)
:red_circle: [codex-lenovo-wsl-1] end (operator stopped)
When Slack supports it, prefer updating the pinned roster message instead of posting many top-level lifecycle lines.
Pinned Roster
The roster is a Slack-pinned cache for fast reads and human visibility. It must be overwritten only after GitHub cross-check, not from Slack silence alone.
Recommended body:
Agent roster (cache, not lock)
Updated: 2026-05-10T13:25:00Z
```json
{
"version": 1,
"updated_at": "2026-05-10T13:25:00Z",
"agents": [
{
"agent_id": "gemini-lenovo-wsl-1",
"last_event": "busy",
"last_seen_at": "2026-05-10T13:24:50Z",
"current_task": "#2186",
"lease_until": "2026-05-10T13:54:50Z",
"stale_reason": null,
"workspace_path": "/home/dev/<REPO>"
}
]
}
```
Roster rules:
presence_stale_after = 20m;updated_atolder than that means "stale cache"; verify GitHub before trusting it. This roster threshold is separate from GitHub claim lease TTL.- Legacy
last_seen_atvalues use the samepresence_stale_after = 20mthreshold and may mark an agent stale only after GitHub shows no fresh PR, branch, commit, or claim renewal. Stale agents getlast_event: "unknown"plusstale_reason: "stale Xm"(the age string lives instale_reason, never inlast_event). - If two agents update concurrently, Slack
chat.updateis last-writer-wins, so the writer must re-read the pinned message immediately before writing, merge peer entries into its own snapshot, and only then post the update. Squashed entries self-heal in the next pre-flight cross-ref against GitHub. - The roster is allowed to lag; active GitHub lease/activity wins over stale roster data.
GitHub Claim Lease
GH-backed work must be claimed with a durable issue/PR comment. Slack reactions and roster entries are visibility only.
Ordering invariant. Posting a claim is forbidden until Required Pre-Flight step 7 returns no-overlap. Adapters that bypass steps 4-6 (cross-checking GitHub for active leases, in-flight issues, fresh PR/commit activity, and peer worktree ownership) are protocol violations — even if the roster cache shows a free slot.
Initial claim marker:
<!-- claim: <AGENT_ID>; lease_until: <ISO8601>; renewed_at: <ISO8601>; task: <type>; branch: <head-ref-or-none>; pr: <number-or-none> -->
Taking #N - <AGENT_ID> (branch: <head-ref-or-none>, PR: <number-or-none>)
task is one of:
quick:lease_until = now + 30min(default for ad-hoc fixes, doc edits)standard:lease_until = now + 2h(typical feature work)long:lease_until = now + 4h(declared upfront with a short reason in the visible comment text — for merge supervisor, content batch, large refactor)
branch and pr are the canonical mapping keys for fresh GitHub activity.
Use none before branch/PR creation, then PATCH the marker before the first
push or immediately after opening the PR.
Renewal cadence: every min(TTL/2, 15min) while executing — refreshes the
GitHub claim AND the roster entry so a healthy agent does not look stale.
Backwards compatibility (Phase 1 markers): legacy claim comments without
lease_until/renewed_at/task/branch/pr are treated as task: quick
with synthetic lease_until = created_at + 4h, preserving the legacy manual
takeover window, and unknown branch/PR mapping. Adapters that touch a legacy
marker during a renewal MUST upgrade it in place to the full format.
Renew by editing the original comment, not by adding a second claim:
GH_TOKEN="${GH_TOKEN:?}" gh api -X PATCH \
"repos/<OWNER>/<REPO>/issues/comments/<comment_id>" \
-f body="$UPDATED_CLAIM_BODY"
Claim race:
Post the claim comment.
Wait 3 seconds.
Re-read all
<!-- claim:and<!-- takeover:comments. Use the explicit query so adapters implement identical sort/select logic:GH_TOKEN="${GH_TOKEN:?}" gh api --paginate \ "repos/<OWNER>/<REPO>/issues/<num>/comments" \ | jq -s 'add | map(select(.body | test("^\\s*<!-- (claim|takeover):")) | {id, created_at, body}) | sort_by(.created_at, .id)'If your own freshly posted comment is missing from the response, GitHub's eventual-consistency window has not closed yet — wait another 2 seconds and retry the read up to 3 times before treating the race as resolved.
Sort by
(created_at, id).Resolve the active lease stream:
- first valid active claim by
(created_at, id)wins when no takeover exists - first valid active takeover by
(created_at, id)supersedes the stale claim it names viaafter: - any unexpired takeover marker blocks fresh claims just like an unexpired claim marker
- first valid active claim by
Losers delete their own claim comments.
Takeover
Never take over from an agent because the Slack roster is silent by itself.
Before evaluating takeover, verify the task is still open and not explicitly blocked for human decision. If that prerequisite fails, stop instead of taking over.
Takeover is allowed only when all 3 gate conditions are true:
lease_until < now- no open PR/draft PR/branch/commit activity for the marker's
branch/prexists that shows fresh progress - no Slack presence message from that
AGENT_IDwithin the relevant TTL window (rosterlast_seen_atis only a cache/optimization)
If Slack presence history cannot be read and the roster is stale/untrusted,
automatic takeover is disallowed; emit pause/blocked and wait for human
decision.
Takeover marker:
<!-- takeover: <AGENT_ID>; after: <PREVIOUS_AGENT_ID>; lease_until: <ISO8601>; renewed_at: <ISO8601>; task: <type>; branch: <head-ref-or-none>; pr: <number-or-none> -->
Taking over #N from <PREVIOUS_AGENT_ID> - <AGENT_ID> (branch: <head-ref-or-none>, PR: <number-or-none>)
Active lease selector:
- Parse both
claimandtakeovermarkers. - If one or more unexpired takeover markers exist, the first valid takeover
by
(created_at, id)is the current owner and supersedes the stale claim named viaafter:. - If no unexpired takeover exists, the first valid active claim by
(created_at, id)is the current owner. - Expired takeover markers do not block a fresh claim unless there is fresh GitHub activity or presence from their holder.
Adapter Responsibilities
Each CLI adapter must define:
- how the skill is loaded at session start
- how Slack channel and pinned roster are found
- how
start,busy,ready,pause,standby, andendare emitted - whether a shell
trap/ launcher wrapper exists for best-effortend - which GitHub token env var to use (
GH_TOKEN_CODEX_BOT,GH_TOKEN_CLAUDE_BOT,GH_TOKEN_GEMINI_BOT, etc.) - how to renew leases during long-running operations
- how to use a launcher wrapper without
execwhen emitting best-effortend
Shutdown hooks are best-effort only. They do not catch hard kill, runtime crash, host power loss, or network loss. Lease expiry plus GitHub activity checks are the recovery mechanism.
Stop Conditions
Stop and ask the human or PM agent when:
- two active agents are editing the same non-generated file set
- another active agent is using the same local checkout and your next step would switch branches, merge, rebase, commit, regenerate tracked files, or clean files
- a lease is expired but there is fresh PR/branch/commit activity
- the task requires overwriting uncommitted changes you did not make
- the roster and GitHub disagree in a way that changes ownership
- secrets, tokens, or private Slack/GitHub identities would need to be exposed
Shared Library
The shared shell library lives at scripts/agent-presence-helpers.sh. Adapters
source it once at startup and delegate all shared logic (pre_flight_check,
claim_issue, renew_lease, emit_presence, update_roster_chat_update).
Required env vars and defaults are documented in the script header.
Placeholders
This skill references these placeholders (defined in
.agent/context/project-ids.md):
<OWNER>/<REPO>— GitHub repository<DISPATCH_CHANNEL>— Slack channel name (e.g.#agent-dispatch)<DISPATCH_CHANNEL_ID>— Slack channel ID (e.g.C0B25SWSUKS)<ROSTER_MESSAGE_TS>— pinned roster message timestamp
References
- Per-CLI adapters:
dispatch-watcher-claude,dispatch-watcher-gemini,dispatch-watcher(Codex) - Shared library:
scripts/agent-presence-helpers.sh