Compare commits
1 Commits
main
...
add-notifi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73f31c75be |
@ -173,84 +173,46 @@ The landing checklist (triggered automatically after every flight) updates
|
|||||||
location, timezone, nearest airport, and lodging in the daily context file. It
|
location, timezone, nearest airport, and lodging in the daily context file. It
|
||||||
also checks if any cron jobs have hardcoded timezones that need updating.
|
also checks if any cron jobs have hardcoded timezones that need updating.
|
||||||
|
|
||||||
### Gitea Notification Delivery
|
### The Gitea Notification Poller
|
||||||
|
|
||||||
There are two approaches for getting Gitea notifications to your agent,
|
OpenClaw has heartbeats, but those are periodic (every ~30min). For Gitea issues
|
||||||
depending on your network setup.
|
and PRs, we wanted near-realtime response. The solution: a tiny Python script
|
||||||
|
that polls the Gitea notifications API every 2 seconds and wakes the agent via
|
||||||
#### Option A: Direct Webhooks (VPS / Public Server)
|
OpenClaw's `/hooks/wake` endpoint when new notifications arrive.
|
||||||
|
|
||||||
If your OpenClaw instance runs on a VPS or other publicly reachable server, the
|
|
||||||
simplest approach is direct webhooks. Run Traefik (or any reverse proxy with
|
|
||||||
automatic TLS) on the same server and configure Gitea webhooks to POST directly
|
|
||||||
to OpenClaw's webhook endpoint. This is push-based and realtime — notifications
|
|
||||||
arrive instantly.
|
|
||||||
|
|
||||||
Setup: add a webhook on each Gitea repo (or use an organization-level webhook)
|
|
||||||
pointing to `https://your-openclaw-host/hooks/gitea`. OpenClaw handles the rest.
|
|
||||||
|
|
||||||
#### Option B: Notification Poller + Dispatcher (Local Machine Behind NAT)
|
|
||||||
|
|
||||||
If your OpenClaw runs on a dedicated local machine behind NAT (like a home Mac
|
|
||||||
or Linux workstation), Gitea can't reach it directly. This is our setup —
|
|
||||||
OpenClaw runs on a Mac Studio on a home LAN.
|
|
||||||
|
|
||||||
The solution: a Python script that both polls and dispatches. It polls the Gitea
|
|
||||||
notifications API every 15 seconds, triages each notification (checking
|
|
||||||
assignment and @-mentions), marks them as read, and spawns one isolated agent
|
|
||||||
session per actionable item via `openclaw cron add --session isolated`.
|
|
||||||
|
|
||||||
The poller also runs a secondary **assignment scan** every 2 minutes, checking
|
|
||||||
all watched repos for open issues/PRs assigned to the bot that were recently
|
|
||||||
updated and still need a response. This catches cases where notifications aren't
|
|
||||||
generated (e.g. self-assignment, API-created issues).
|
|
||||||
|
|
||||||
Key design decisions:
|
Key design decisions:
|
||||||
|
|
||||||
- **The poller IS the dispatcher.** No flag files, no heartbeat dependency. The
|
- **The poller never marks notifications as read.** That's the agent's job after
|
||||||
poller triages notifications and spawns agents directly.
|
it processes them. This prevents the poller and agent from racing.
|
||||||
- **Marks notifications as read immediately.** Each notification is marked read
|
- **It tracks notification IDs, not counts.** This way it only fires on
|
||||||
as it's processed, preventing re-dispatch on the next poll.
|
genuinely new notifications, not re-reads of existing ones.
|
||||||
- **One agent per issue.** Each spawned agent gets a `SCOPE` instruction
|
- **The wake message tells the agent to route output to Gitea/Mattermost, not to
|
||||||
limiting it to one specific issue/PR. Agents post results as Gitea comments,
|
DM.** This prevents chatty notification processing from disturbing the human.
|
||||||
not DMs.
|
- **Zero dependencies.** Just Python stdlib (`urllib`, `json`, `time`). Runs
|
||||||
- **Dedup tracking.** An in-memory `dispatched_issues` set prevents spawning
|
anywhere.
|
||||||
multiple agents for the same issue within one poller lifetime.
|
|
||||||
- **`--no-deliver` instead of `--announce`.** Agents report via Gitea API
|
|
||||||
directly. The `--announce` flag on isolated sessions had delivery failures.
|
|
||||||
- **Assignment scan filters by recency.** Only issues updated in the last 5
|
|
||||||
minutes are considered, preventing re-dispatch for stale assigned issues.
|
|
||||||
- **Zero dependencies.** Just Python stdlib. Runs anywhere.
|
|
||||||
|
|
||||||
Response time: ~15–60s from notification to agent comment (vs ~30 min with the
|
Here's the full source:
|
||||||
old heartbeat approach).
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Gitea notification poller + dispatcher.
|
Gitea notification poller.
|
||||||
|
Polls for unread notifications and wakes OpenClaw when the count
|
||||||
Two polling loops:
|
changes. The AGENT marks notifications as read after processing —
|
||||||
1. Notification-based: detects new notifications (mentions, assignments by
|
the poller never marks anything as read.
|
||||||
other users) and dispatches agents for actionable ones.
|
|
||||||
2. Assignment-based: periodically checks for open issues/PRs assigned to
|
|
||||||
the bot that have no recent bot comment. Catches cases where
|
|
||||||
notifications aren't generated (e.g. self-assignment, API-created issues).
|
|
||||||
|
|
||||||
Required env vars:
|
Required env vars:
|
||||||
GITEA_URL - Gitea instance URL
|
GITEA_URL - Gitea instance URL
|
||||||
GITEA_TOKEN - Gitea API token
|
GITEA_TOKEN - Gitea API token
|
||||||
|
HOOK_TOKEN - OpenClaw hooks auth token
|
||||||
|
|
||||||
Optional env vars:
|
Optional env vars:
|
||||||
POLL_DELAY - Delay between polls in seconds (default: 15)
|
GATEWAY_URL - OpenClaw gateway URL (default: http://127.0.0.1:18789)
|
||||||
COOLDOWN - Minimum seconds between dispatches (default: 30)
|
POLL_DELAY - Delay between polls in seconds (default: 2)
|
||||||
ASSIGNMENT_INTERVAL - Seconds between assignment scans (default: 120)
|
|
||||||
OPENCLAW_BIN - Path to openclaw binary
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@ -258,158 +220,109 @@ import urllib.error
|
|||||||
|
|
||||||
GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/")
|
GITEA_URL = os.environ.get("GITEA_URL", "").rstrip("/")
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||||
POLL_DELAY = int(os.environ.get("POLL_DELAY", "15"))
|
GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://127.0.0.1:18789").rstrip(
|
||||||
COOLDOWN = int(os.environ.get("COOLDOWN", "30"))
|
"/"
|
||||||
ASSIGNMENT_INTERVAL = int(os.environ.get("ASSIGNMENT_INTERVAL", "120"))
|
)
|
||||||
OPENCLAW_BIN = os.environ.get("OPENCLAW_BIN", "/opt/homebrew/bin/openclaw")
|
HOOK_TOKEN = os.environ.get("HOOK_TOKEN", "")
|
||||||
BOT_USER = "clawbot" # Change to your bot's Gitea username
|
POLL_DELAY = int(os.environ.get("POLL_DELAY", "2"))
|
||||||
|
|
||||||
# Repos to scan for assigned issues
|
|
||||||
WATCHED_REPOS = [
|
|
||||||
# "org/repo1",
|
|
||||||
# "org/repo2",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Track dispatched issues to prevent duplicates
|
|
||||||
dispatched_issues = set()
|
|
||||||
|
|
||||||
|
|
||||||
def gitea_api(method, path, data=None):
|
def check_config():
|
||||||
url = f"{GITEA_URL}/api/v1{path}"
|
missing = []
|
||||||
body = json.dumps(data).encode() if data else None
|
if not GITEA_URL:
|
||||||
headers = {"Authorization": f"token {GITEA_TOKEN}"}
|
missing.append("GITEA_URL")
|
||||||
if body:
|
if not GITEA_TOKEN:
|
||||||
headers["Content-Type"] = "application/json"
|
missing.append("GITEA_TOKEN")
|
||||||
req = urllib.request.Request(url, headers=headers, method=method, data=body)
|
if not HOOK_TOKEN:
|
||||||
try:
|
missing.append("HOOK_TOKEN")
|
||||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
if missing:
|
||||||
raw = resp.read()
|
print(
|
||||||
return json.loads(raw) if raw else None
|
f"ERROR: Missing required env vars: {', '.join(missing)}",
|
||||||
except Exception as e:
|
file=sys.stderr,
|
||||||
print(f"WARN: {method} {path}: {e}", file=sys.stderr, flush=True)
|
)
|
||||||
return None
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def needs_bot_response(repo_full, issue_number):
|
def gitea_unread_ids():
|
||||||
"""True if the bot is NOT the author of the most recent comment."""
|
"""Return set of unread notification IDs."""
|
||||||
comments = gitea_api("GET", f"/repos/{repo_full}/issues/{issue_number}/comments")
|
req = urllib.request.Request(
|
||||||
if comments and len(comments) > 0:
|
f"{GITEA_URL}/api/v1/notifications?status-types=unread",
|
||||||
if comments[-1].get("user", {}).get("login") == BOT_USER:
|
headers={"Authorization": f"token {GITEA_TOKEN}"},
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_actionable(notif):
|
|
||||||
"""Returns (actionable, reason, issue_number)."""
|
|
||||||
subject = notif.get("subject", {})
|
|
||||||
repo = notif.get("repository", {})
|
|
||||||
repo_full = repo.get("full_name", "")
|
|
||||||
url = subject.get("url", "")
|
|
||||||
number = url.rstrip("/").split("/")[-1] if url else ""
|
|
||||||
if not number or not number.isdigit():
|
|
||||||
return False, "no issue number", None
|
|
||||||
|
|
||||||
issue = gitea_api("GET", f"/repos/{repo_full}/issues/{number}")
|
|
||||||
if not issue:
|
|
||||||
return False, "couldn't fetch issue", number
|
|
||||||
|
|
||||||
assignees = [a.get("login") for a in (issue.get("assignees") or [])]
|
|
||||||
if BOT_USER in assignees:
|
|
||||||
if needs_bot_response(repo_full, number):
|
|
||||||
return True, f"assigned to {BOT_USER}", number
|
|
||||||
return False, "assigned but already responded", number
|
|
||||||
|
|
||||||
issue_body = issue.get("body", "") or ""
|
|
||||||
if f"@{BOT_USER}" in issue_body and issue.get("user", {}).get("login") != BOT_USER:
|
|
||||||
if needs_bot_response(repo_full, number):
|
|
||||||
return True, f"@-mentioned in body", number
|
|
||||||
|
|
||||||
comments = gitea_api("GET", f"/repos/{repo_full}/issues/{number}/comments")
|
|
||||||
if comments:
|
|
||||||
last = comments[-1]
|
|
||||||
if last.get("user", {}).get("login") == BOT_USER:
|
|
||||||
return False, "own comment is latest", number
|
|
||||||
if f"@{BOT_USER}" in (last.get("body") or ""):
|
|
||||||
return True, f"@-mentioned in comment", number
|
|
||||||
|
|
||||||
return False, "not mentioned or assigned", number
|
|
||||||
|
|
||||||
|
|
||||||
def spawn_agent(repo_full, issue_number, title, subject_type, reason):
|
|
||||||
dispatch_key = f"{repo_full}#{issue_number}"
|
|
||||||
if dispatch_key in dispatched_issues:
|
|
||||||
return
|
|
||||||
dispatched_issues.add(dispatch_key)
|
|
||||||
|
|
||||||
repo_short = repo_full.split("/")[-1]
|
|
||||||
job_name = f"gitea-{repo_short}-{issue_number}-{int(time.time())}"
|
|
||||||
msg = (
|
|
||||||
f"Gitea: {reason} on {subject_type} #{issue_number} "
|
|
||||||
f"'{title}' in {repo_full}.\n"
|
|
||||||
f"API: {GITEA_URL}/api/v1 | Token: {GITEA_TOKEN}\n"
|
|
||||||
f"SCOPE: Only {subject_type} #{issue_number} in {repo_full}.\n"
|
|
||||||
f"Read all comments, do the work, post results as Gitea comments."
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
[OPENCLAW_BIN, "cron", "add",
|
notifs = json.loads(resp.read())
|
||||||
"--name", job_name, "--at", "1s",
|
return {n["id"] for n in notifs}
|
||||||
"--message", msg, "--delete-after-run",
|
|
||||||
"--session", "isolated", "--no-deliver",
|
|
||||||
"--thinking", "low", "--timeout-seconds", "300"],
|
|
||||||
capture_output=True, text=True, timeout=15,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Spawn error: {e}", file=sys.stderr, flush=True)
|
print(
|
||||||
dispatched_issues.discard(dispatch_key)
|
f"WARN: Gitea API failed: {e}", file=sys.stderr, flush=True
|
||||||
|
)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def wake_openclaw(count):
|
||||||
|
text = (
|
||||||
|
f"[Gitea Notification] {count} new notification(s). "
|
||||||
|
"Check your Gitea notification inbox via API, process them, "
|
||||||
|
"and mark as read when done. "
|
||||||
|
"Route all output to Gitea comments or Mattermost #git/#claw. "
|
||||||
|
"Do NOT reply to this session — respond with NO_REPLY."
|
||||||
|
)
|
||||||
|
payload = json.dumps({"text": text, "mode": "now"}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{GATEWAY_URL}/hooks/wake",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {HOOK_TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
status = resp.status
|
||||||
|
print(f" Wake responded: {status}", flush=True)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(
|
||||||
|
f"WARN: Failed to wake OpenClaw: {e}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print(f"Poller started (poll={POLL_DELAY}s, cooldown={COOLDOWN}s)", flush=True)
|
check_config()
|
||||||
seen_ids = set(n["id"] for n in (gitea_api("GET", "/notifications?status-types=unread") or []))
|
print(
|
||||||
last_dispatch = 0
|
f"Gitea notification poller started (delay={POLL_DELAY}s)",
|
||||||
last_assign_scan = 0
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_seen_ids = gitea_unread_ids()
|
||||||
|
print(
|
||||||
|
f"Initial unread: {len(last_seen_ids)} notification(s)", flush=True
|
||||||
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(POLL_DELAY)
|
time.sleep(POLL_DELAY)
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Notification polling
|
current_ids = gitea_unread_ids()
|
||||||
notifs = gitea_api("GET", "/notifications?status-types=unread") or []
|
new_ids = current_ids - last_seen_ids
|
||||||
current_ids = {n["id"] for n in notifs}
|
|
||||||
new_ids = current_ids - seen_ids
|
|
||||||
if new_ids and now - last_dispatch >= COOLDOWN:
|
|
||||||
for n in [n for n in notifs if n["id"] in new_ids]:
|
|
||||||
nid = n.get("id")
|
|
||||||
if nid:
|
|
||||||
gitea_api("PATCH", f"/notifications/threads/{nid}")
|
|
||||||
is_act, reason, num = is_actionable(n)
|
|
||||||
if is_act:
|
|
||||||
repo = n["repository"]["full_name"]
|
|
||||||
title = n["subject"]["title"][:60]
|
|
||||||
stype = n["subject"].get("type", "").lower()
|
|
||||||
spawn_agent(repo, num, title, stype, reason)
|
|
||||||
last_dispatch = now
|
|
||||||
seen_ids = current_ids
|
|
||||||
|
|
||||||
# Assignment scan (less frequent)
|
if not new_ids:
|
||||||
if now - last_assign_scan >= ASSIGNMENT_INTERVAL:
|
last_seen_ids = current_ids
|
||||||
for repo in WATCHED_REPOS:
|
continue
|
||||||
for itype in ["issues", "pulls"]:
|
|
||||||
items = gitea_api("GET",
|
ts = time.strftime("%H:%M:%S")
|
||||||
f"/repos/{repo}/issues?state=open&type={itype}"
|
print(
|
||||||
f"&assignee={BOT_USER}&sort=updated&limit=10") or []
|
f"[{ts}] {len(new_ids)} new notification(s) "
|
||||||
for item in items:
|
f"({len(current_ids)} total unread), waking agent",
|
||||||
num = str(item["number"])
|
flush=True,
|
||||||
if f"{repo}#{num}" in dispatched_issues:
|
)
|
||||||
continue
|
|
||||||
# Only recently updated items (5 min)
|
wake_openclaw(len(new_ids))
|
||||||
# ... add is_recently_updated() check here
|
last_seen_ids = current_ids
|
||||||
if needs_bot_response(repo, num):
|
|
||||||
spawn_agent(repo, num, item["title"][:60],
|
|
||||||
"pull" if itype == "pulls" else "issue",
|
|
||||||
f"assigned to {BOT_USER}")
|
|
||||||
last_assign_scan = now
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@ -455,15 +368,13 @@ This applies to everything: project rules ("no mocks in tests"), workflow
|
|||||||
preferences ("fewer PRs, don't over-split"), corrections, new policies.
|
preferences ("fewer PRs, don't over-split"), corrections, new policies.
|
||||||
Immediate write to the daily file, and to MEMORY.md if it's a standing rule.
|
Immediate write to the daily file, and to MEMORY.md if it's a standing rule.
|
||||||
|
|
||||||
### Sensitive Output Routing
|
### PII-Aware Output Routing
|
||||||
|
|
||||||
A lesson learned the hard way: **the audience determines what you can say, not
|
A lesson learned the hard way: **the audience determines what you can say, not
|
||||||
who asked.** If the human asks for a medication status report in a group
|
who asked.** If the human asks for a medication status report in a group
|
||||||
channel, the agent can't just dump it there — other people can read it. The
|
channel, the agent can't just dump it there — other people can read it. The
|
||||||
rule: if the output would contain sensitive information (PII, secrets,
|
rule: if the output would contain PII and the channel isn't private, redirect to
|
||||||
credentials, API keys, operational details like flight numbers, locations,
|
DM and reply in-channel with "sent privately."
|
||||||
travel plans, medical info, etc.) and the channel isn't private, redirect to DM
|
|
||||||
and reply in-channel with "sent privately."
|
|
||||||
|
|
||||||
This is enforced at multiple levels:
|
This is enforced at multiple levels:
|
||||||
|
|
||||||
@ -494,7 +405,7 @@ The heartbeat handles:
|
|||||||
- Periodic memory maintenance
|
- Periodic memory maintenance
|
||||||
|
|
||||||
State tracking in `memory/heartbeat-state.json` prevents redundant checks (e.g.,
|
State tracking in `memory/heartbeat-state.json` prevents redundant checks (e.g.,
|
||||||
don't re-check notifications if you checked 10 minutes ago).
|
don't re-check email if you checked 10 minutes ago).
|
||||||
|
|
||||||
The key output rule: heartbeats should either be `HEARTBEAT_OK` (nothing to do)
|
The key output rule: heartbeats should either be `HEARTBEAT_OK` (nothing to do)
|
||||||
or a direct alert. Work narration goes to a designated status channel, never to
|
or a direct alert. Work narration goes to a designated status channel, never to
|
||||||
@ -1506,8 +1417,7 @@ stay quiet.
|
|||||||
|
|
||||||
## Inbox Check (PRIORITY)
|
## Inbox Check (PRIORITY)
|
||||||
|
|
||||||
(check whatever notification sources apply to your setup — e.g. Gitea
|
(check notifications, issues, emails — whatever applies)
|
||||||
notifications, emails, issue trackers)
|
|
||||||
|
|
||||||
## Flight Prep Blocks (daily)
|
## Flight Prep Blocks (daily)
|
||||||
|
|
||||||
@ -1541,9 +1451,10 @@ Never send internal thinking or status narration to user's DM. Output should be:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"lastChecks": {
|
"lastChecks": {
|
||||||
"gitea": 1703280000,
|
"email": 1703275200,
|
||||||
"calendar": 1703260800,
|
"calendar": 1703260800,
|
||||||
"weather": null
|
"weather": null,
|
||||||
|
"gitea": 1703280000
|
||||||
},
|
},
|
||||||
"lastWeeklyDocsReview": "2026-02-24"
|
"lastWeeklyDocsReview": "2026-02-24"
|
||||||
}
|
}
|
||||||
@ -1571,9 +1482,51 @@ Never send internal thinking or status narration to user's DM. Output should be:
|
|||||||
|
|
||||||
## Gitea Integration & Notification Polling
|
## Gitea Integration & Notification Polling
|
||||||
|
|
||||||
For self-hosted Gitea instances, you can set up a notification poller that
|
For self-hosted Gitea instances, you need a way to deliver notifications (issue
|
||||||
injects Gitea events (issue assignments, PR reviews, @-mentions) into the
|
assignments, PR reviews, @-mentions) to your agent. There are two approaches,
|
||||||
agent's session.
|
depending on your network setup.
|
||||||
|
|
||||||
|
### Notification Delivery: Webhooks vs Polling
|
||||||
|
|
||||||
|
#### 1. Direct webhooks (VPS / public server)
|
||||||
|
|
||||||
|
If your OpenClaw instance runs on a VPS or other publicly reachable server, you
|
||||||
|
can run Traefik (or any reverse proxy) on the same server and configure Gitea
|
||||||
|
webhooks to POST directly to OpenClaw's webhook endpoint. This is push-based and
|
||||||
|
realtime — notifications arrive instantly.
|
||||||
|
|
||||||
|
Set up a Gitea webhook (per-repo or org-wide) pointing at your OpenClaw
|
||||||
|
instance's `/hooks/wake` endpoint. Gitea sends a POST on every event, and the
|
||||||
|
agent wakes immediately to process it.
|
||||||
|
|
||||||
|
#### 2. Notification poller (local machine behind NAT)
|
||||||
|
|
||||||
|
If your OpenClaw instance runs on a dedicated local machine behind NAT (like a
|
||||||
|
home Mac or Linux box), Gitea can't reach it directly. In this case, use a
|
||||||
|
lightweight polling script that checks the Gitea notifications API every few
|
||||||
|
seconds and signals the agent when new notifications arrive.
|
||||||
|
|
||||||
|
This is the approach we use — OpenClaw runs on a dedicated Mac Studio on a home
|
||||||
|
LAN, so we poll Gitea's notification API and wake the agent via the local
|
||||||
|
`/hooks/wake` endpoint when new notifications appear. The poller script is
|
||||||
|
included below in the [Notification poller](#notification-poller) section.
|
||||||
|
|
||||||
|
The poller approach trades ~30 seconds of latency (polling interval) for
|
||||||
|
simplicity and no NAT/firewall configuration. For most workflows this is
|
||||||
|
perfectly fine — code review and issue triage don't need sub-second response
|
||||||
|
times. If no new notifications arrive between heartbeats, the effective latency
|
||||||
|
is bounded by the heartbeat interval (~30 minutes), but in practice the poller
|
||||||
|
catches most events within seconds.
|
||||||
|
|
||||||
|
#### Which should you choose?
|
||||||
|
|
||||||
|
| Factor | Webhooks | Poller |
|
||||||
|
| ------------------- | ------------------- | ------------------------- |
|
||||||
|
| Network requirement | Public IP / domain | None (outbound-only) |
|
||||||
|
| Latency | Instant | ~2-30s (polling interval) |
|
||||||
|
| Setup complexity | Reverse proxy + TLS | Single background script |
|
||||||
|
| Dependencies | Traefik/nginx/Caddy | Python stdlib only |
|
||||||
|
| Best for | VPS / cloud deploys | Home LAN / NAT setups |
|
||||||
|
|
||||||
### Workflow rules (HEARTBEAT.md / AGENTS.md):
|
### Workflow rules (HEARTBEAT.md / AGENTS.md):
|
||||||
|
|
||||||
@ -1712,24 +1665,21 @@ Never lose a rule or preference your human states:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sensitive Output Routing — Audience-Aware Responses
|
## PII Output Routing — Audience-Aware Responses
|
||||||
|
|
||||||
A critical security pattern: **the audience determines what you can say, not who
|
A critical security pattern: **the audience determines what you can say, not who
|
||||||
asked.** If your human asks for a sitrep (or any sensitive info) in a group
|
asked.** If your human asks for a sitrep (or any PII-containing info) in a group
|
||||||
channel, you can't just dump it there — other people can read it.
|
channel, you can't just dump it there — other people can read it.
|
||||||
|
|
||||||
### AGENTS.md / checklist prompt:
|
### AGENTS.md / checklist prompt:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Sensitive Output Routing (CRITICAL)
|
## PII Output Routing (CRITICAL)
|
||||||
|
|
||||||
- NEVER output sensitive information in any non-private channel, even if your
|
- NEVER output PII in any non-private channel, even if your human asks for it
|
||||||
human asks for it
|
- If a request would produce PII (medication status, travel details, financial
|
||||||
- This includes: PII, secrets, credentials, API keys, and sensitive operational
|
info, etc.) in a shared channel: send the response via DM instead, and reply
|
||||||
information (flight numbers/times/dates, locations, travel plans, medical
|
in-channel with "sent privately"
|
||||||
info, financial details, etc.)
|
|
||||||
- If a request would produce any of the above in a shared channel: send the
|
|
||||||
response via DM instead, and reply in-channel with "sent privately"
|
|
||||||
- The rule is: the audience determines what you can say, not who asked
|
- The rule is: the audience determines what you can say, not who asked
|
||||||
- This applies to: group chats, public issue trackers, shared Mattermost
|
- This applies to: group chats, public issue trackers, shared Mattermost
|
||||||
channels, Discord servers — anywhere that isn't a 1:1 DM
|
channels, Discord servers — anywhere that isn't a 1:1 DM
|
||||||
@ -1738,10 +1688,10 @@ channel, you can't just dump it there — other people can read it.
|
|||||||
### Why this matters:
|
### Why this matters:
|
||||||
|
|
||||||
This is a real failure mode. If someone asks "sitrep" in a group channel and you
|
This is a real failure mode. If someone asks "sitrep" in a group channel and you
|
||||||
respond with medication names, partner details, travel dates, hotel names, or
|
respond with medication names, partner details, travel dates, and hotel names —
|
||||||
API credentials — you just leaked all of that to everyone in the channel. The
|
you just leaked all of that to everyone in the channel. The human asking is
|
||||||
human asking is authorized to see it; the channel audience is not. Always check
|
authorized to see it; the channel audience is not. Always check WHERE you're
|
||||||
WHERE you're responding, not just WHO asked.
|
responding, not just WHO asked.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user