Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ LAN URL to paste into CodeWatch — the upcoming mobile + watch companion app.
- [Env Vars (Generic Poller)](#env-vars-generic-poller)
- [Env Vars (Codex Smart Poller)](#env-vars-codex-smart-poller)
- [Background Consolidation](#background-consolidation)
- [Peer Wake](#peer-wake-same-machine-agents-revive-sleeping-colleagues)
- [Integrations](#integrations)
- [GitHub Webhooks](#github-webhooks-srcwebhook-servermjs)
- [OpenClaw Bot Fleet](#openclaw-bot-fleet-srcopenclaw-mjs)
Expand Down Expand Up @@ -489,6 +490,87 @@ Latest local verification from the Petrus machine, dogfooded against `v0.6.1`:
- `GET /intent/petrus` returned `200` on `2026-04-08`, with `agents=[claudemb, claudemm]` and `devices=[macbook, mac-mini]` both active and zero stale slots after the UIK v0.2.2 deployment.
- `GET /api/search/observations?query=thinkoff&limit=3` returned `200` on `2026-03-31` (claude-mem path unchanged since v0.5.0).

## Peer wake (same-machine agents revive sleeping colleagues)

Pollers and webhooks wake an agent whose receiver is running. But when an agent's
IDE goes quiet — the session wedged, the tunnel died silently, the app lost focus —
nothing is listening to wake it. **Peer wake** closes that gap: a watchdog running
in the always-on layer of a machine can revive any agent whose IDE lives on the
**same machine**, driving its GUI directly with no network dependency.

`scripts/team-watchdog.mjs` is that watchdog. Every interval it reads the room,
computes each roster agent's last-seen timestamp, and for anyone that has gone
quiet past `STALE_MIN` it fires exactly one wake path — rate-limited by a cooldown
and an `MAX_NUDGES` cap so it never spams.

**The model — who can wake whom:**

```
machine M (always-on watchdog)
├─ localWake → agent's IDE is ON machine M → GUI-wake it directly,
│ works even with no network
├─ gate → agent is on ANOTHER machine → POST <gate>/wake to its
│ daemon — only lands if that
│ machine is awake + receiver up
└─ (neither) → gateless agent in the room → room @mention its poller catches
```

A watchdog only *directly* revives agents whose IDE runs on its own machine
(`localWake`). Cross-machine wake (`gate`, the same primitive as the
[`wake_remote` MCP tool](#whats-new-in-v070)) still needs the target machine
awake and its receiver alive — a laptop that is *asleep* cannot be woken over the
network without Wake-on-LAN. That is the one thing peer wake cannot do; run a
watchdog **on each machine** so every agent has a local reviver.

**Roster format** — `config/watchdog-roster.json` (gitignored; it holds hosts and
machine paths). Copy [`config/watchdog-roster.example.json`](config/watchdog-roster.example.json)
and edit it for the agents that live on *this* machine. Each entry names one wake path:

```json
[
{ "handle": "@codex-on-this-mac", "localWake": "/abs/path/ide-agent-kit/tools/codex_gui_nudge.sh" },
{ "handle": "@agent-elsewhere", "gate": "http://192.168.0.9:8788" },
{ "handle": "@gateless-agent" }
]
```

The roster can also come from `IAK_WATCHDOG_ROSTER` (inline JSON) or
`IAK_WATCHDOG_ROSTER_FILE`. No roster → nothing to watch (the watchdog logs and
idles), so it is safe to run everywhere.

**Safety: never types over a human.** `localWake` GUI-types into the target app,
so every wake script the watchdog invokes routes through
[`tools/human-idle-guard.sh`](tools/human-idle-guard.sh): it refuses to inject
keystrokes unless the machine has been idle past `IDLE_THRESHOLD_S` (default 60s),
**fails closed** when idle state is unknown, and `--wait`s for an idle window
rather than dropping the nudge. The shipped wake scripts (`scripts/claude-gui-wake.sh`,
`tools/codex_gui_nudge.sh`) already call it (and also refuse to type into a locked
screen or a non-frontmost window). **Any custom script you point `localWake` at
MUST be idle-guarded too** — the watchdog runs it verbatim.

**Opt-in install (per machine).** The watchdog is off by default — it needs a
roster. Load it with launchd from the parameterized example:

```bash
# 1. create your roster from the example
sed "s|REPLACE_WITH_IAK_ROOT|$HOME/ide-agent-kit|g" \
config/watchdog-roster.example.json > config/watchdog-roster.json
# then edit handles/paths for the agents whose IDEs run on THIS Mac

# 2. install + load the LaunchAgent (KeepAlive; restarts if it exits)
sed "s|REPLACE_WITH_IAK_ROOT|$HOME/ide-agent-kit|g" \
examples/team-watchdog-launchd.plist \
> ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist
launchctl load ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist
```

Or let the installer do it: `IAK_INSTALL_WATCHDOG=1` makes `scripts/install.sh`
install the LaunchAgent (only if a roster exists). On a laptop that sleeps, prefer
the one-shot variant documented in the plist header (`ONCE=1` + `StartInterval`),
since the in-process interval timer stalls across sleep. Tunables (all env, all
optional): `STALE_MIN`, `COOLDOWN_MIN`, `INTERVAL_MIN`, `MAX_NUDGES`, `ROOM`,
`WATCHDOG_SELF`, `DRY_RUN` (detect + log only, no wakes/posts).

## Integrations

### MCP server (`src/mcp-server.mjs`)
Expand Down
17 changes: 17 additions & 0 deletions config/watchdog-roster.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{
"handle": "@codex-on-this-mac",
"localWake": "REPLACE_WITH_IAK_ROOT/tools/codex_gui_nudge.sh"
},
{
"handle": "@claude-on-this-mac",
"localWake": "REPLACE_WITH_IAK_ROOT/scripts/claude-gui-wake.sh"
},
{
"handle": "@agent-on-another-machine",
"gate": "http://REPLACE_WITH_PEER_HOST:8788"
},
{
"handle": "@gateless-agent-in-the-room"
}
]
62 changes: 62 additions & 0 deletions examples/team-watchdog-launchd.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
launchd wiring for the peer-wake team-watchdog (opt-in, one per machine).

The watchdog is the ALWAYS-ON layer that revives sleeping colleagues: every
interval it reads the room and, for any roster agent that has gone quiet, runs
its wake path (same-machine localWake GUI nudge, cross-machine gate POST, or a
room @mention). See README "Peer wake" and config/watchdog-roster.example.json.

Install (launchd cannot expand $HOME, so bake the checkout path in with sed):
mkdir -p ~/Library/LaunchAgents
sed "s|REPLACE_WITH_IAK_ROOT|$HOME/ide-agent-kit|g" \
examples/team-watchdog-launchd.plist \
> ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist
launchctl load ~/Library/LaunchAgents/com.thinkoff.iak-team-watchdog.plist

Prereq: a real roster at REPLACE_WITH_IAK_ROOT/config/watchdog-roster.json
(copy config/watchdog-roster.example.json and sed the same path in). The real
roster is gitignored — never commit hosts or machine paths.

KeepAlive restarts the long-running loop if it exits. On a LAPTOP that sleeps,
the in-process interval timer stalls across sleep; prefer the one-shot variant
instead — set ONCE=1 in EnvironmentVariables and swap KeepAlive for:
<key>StartInterval</key><integer>300</integer>
so launchd re-runs a single tick every 5 minutes regardless of sleep/wake.

Optional EnvironmentVariables (see scripts/team-watchdog.mjs header): ROOM,
STALE_MIN, COOLDOWN_MIN, INTERVAL_MIN, MAX_NUDGES, WATCHDOG_SELF, DRY_RUN,
GROUPMIND_KEY. If GROUPMIND_KEY is unset it is read from config (poller.api_key).
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.thinkoff.iak-team-watchdog</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>cd REPLACE_WITH_IAK_ROOT &amp;&amp; exec node scripts/team-watchdog.mjs</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>IAK_ROOT</key>
<string>REPLACE_WITH_IAK_ROOT</string>
<key>IAK_CONFIG</key>
<string>REPLACE_WITH_IAK_ROOT/ide-agent-kit.json</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>/tmp/iak-team-watchdog.launchd.log</string>
<key>StandardErrorPath</key>
<string>/tmp/iak-team-watchdog.launchd.log</string>
</dict>
</plist>
29 changes: 29 additions & 0 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,35 @@ tmux new-session -d -s "$TMUX_SESSION" \
"cd $INSTALL_DIR && node bin/iak-mcp-daemon.mjs"
sleep 2

# 6b. Optional: peer-wake team-watchdog (opt-in; needs a roster).
# OFF by default — enable with IAK_INSTALL_WATCHDOG=1. Revives sleeping
# colleagues whose IDEs run on THIS machine; see README "Peer wake".
if [ "${IAK_INSTALL_WATCHDOG:-0}" = "1" ]; then
ROSTER_FILE="$INSTALL_DIR/config/watchdog-roster.json"
if [ ! -f "$ROSTER_FILE" ]; then
yellow "IAK_INSTALL_WATCHDOG=1 but no roster at $ROSTER_FILE — skipping."
echo " Create one from the example, then re-run with IAK_INSTALL_WATCHDOG=1:"
echo " sed \"s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g\" \\"
echo " $INSTALL_DIR/config/watchdog-roster.example.json > $ROSTER_FILE"
echo " # then edit handles/paths for the agents whose IDEs run on THIS Mac"
else
LA_DIR="$HOME/Library/LaunchAgents"
PLIST="$LA_DIR/com.thinkoff.iak-team-watchdog.plist"
mkdir -p "$LA_DIR"
sed "s|REPLACE_WITH_IAK_ROOT|$INSTALL_DIR|g" \
"$INSTALL_DIR/examples/team-watchdog-launchd.plist" > "$PLIST"
yellow "Installed peer-wake watchdog LaunchAgent → $PLIST"
if launchctl list 2>/dev/null | grep -q com.thinkoff.iak-team-watchdog; then
echo " Already loaded. Reload after edits:"
echo " launchctl unload \"$PLIST\" && launchctl load \"$PLIST\""
elif launchctl load "$PLIST" 2>/dev/null; then
green " Loaded (KeepAlive; reads $ROSTER_FILE)."
else
yellow " Load it manually: launchctl load \"$PLIST\""
fi
fi
fi

# 7. report
LAN_IP=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo "127.0.0.1")
LAN_URL="http://${LAN_IP}:8788"
Expand Down
Loading
Loading