diff --git a/README.md b/README.md index 101ff23..ed3732d 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,11 @@ Grok Build has no Claude `SessionStart` hook. Copy 3. Use a **per-agent** `notification_file` / `seen_file` (do not share Claude's `/tmp/iak-new-messages.txt`). +**Footgun 2:** two wake paths (a webhook receiver *and* the poller nudge, or the +app's own scheduled check) answer every mention twice. Declare the one that owns +the wake in `poller.wake_path` (`nudge` | `webhook` | `automation` | `none`); +the poller refuses to nudge unless it is `nudge`. + **Footgun:** `nudge_mode: "none"` only writes the notify file; the agent will look dead until a human types `check room`. Full write-up: [docs/grok-build.md](docs/grok-build.md). diff --git a/config/codex.desktop.example.json b/config/codex.desktop.example.json index b2c6365..f04ab75 100644 --- a/config/codex.desktop.example.json +++ b/config/codex.desktop.example.json @@ -36,7 +36,8 @@ "heartbeat_file": "/tmp/iak-poller.heartbeat", "handle": "@CodexMB", "interval_sec": 60, - "nudge_mode": "command", + "wake_path": "webhook", + "nudge_mode": "none", "nudge_command": "/ABSOLUTE/PATH/ide-agent-kit/tools/codex_gui_nudge.sh", "notification_file": "/tmp/codex-room-notifications.txt", "seen_file": "/tmp/codex-room-seen.txt" diff --git a/docs/AGENT-ONBOARDING.md b/docs/AGENT-ONBOARDING.md index 8c4466f..501d8d3 100644 --- a/docs/AGENT-ONBOARDING.md +++ b/docs/AGENT-ONBOARDING.md @@ -75,6 +75,14 @@ forever. Pick per runtime: Another agent posts a question addressed to the new agent, **everyone hands off keyboards**, and the new agent answers by itself. If silence: check, in order — poller running? notification file growing? wake path firing? +**One wake path.** Set `poller.wake_path` to the single thing that wakes the +agent: `"nudge"` (this poller's tmux/command nudge, the default), `"webhook"` +(a webhook receiver wakes it), `"automation"` (the app's own scheduled check) +or `"none"`. With `webhook` or `automation` the poller still delivers the +notification file but refuses to nudge, and warns at start if `nudge_mode` is +also set. Two wake paths mean two answers to every mention (codexmb, +2026-09-01). + (@grok's failure was step 4: `nudge_mode` was `"none"`.) ## 6. Key hygiene diff --git a/src/config.mjs b/src/config.mjs index 90c0e4a..64df2a8 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -16,7 +16,11 @@ const DEFAULT_CONFIG = { seen_file: '/tmp/iak-seen-ids.txt', api_key: '', nudge_mode: 'tmux', - nudge_command: '' + nudge_command: '', + // Which path wakes the agent: 'nudge' (this poller), 'webhook' (a + // webhook receiver), 'automation' (the app's own scheduled check) or + // 'none'. Exactly one. Empty = 'nudge' unless nudge_mode is 'none'. + wake_path: '' }, dm_poller: { enabled: false, diff --git a/src/team-relay/room-poller.mjs b/src/team-relay/room-poller.mjs index 903e54f..ef9bb7a 100644 --- a/src/team-relay/room-poller.mjs +++ b/src/team-relay/room-poller.mjs @@ -176,6 +176,12 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config, const session = sessionOpt || config?.tmux?.ide_session || config?.tmux?.default_session || 'claude'; const nudgeText = config?.tmux?.nudge_text || 'check rooms'; const nudgeMode = config?.poller?.nudge_mode || 'tmux'; + // Exactly one wake path per agent (issue #90 item 4). On 2026-09-01 the + // codexmb poller nudged on top of a webhook receiver and the app's own + // scheduled check, and every mention got two or three answers. The path + // is a config fact: this poller nudges only when it owns the wake. + const wakePath = String(config?.poller?.wake_path || (nudgeMode === 'none' ? 'none' : 'nudge')).toLowerCase(); + const pollerOwnsWake = wakePath === 'nudge'; const nudgeCommandText = config?.poller?.nudge_command || ''; const pollInterval = parsePositiveInt(interval || config?.poller?.interval_sec, 30); const selfHandle = normalizeHandle(resolveSelfHandle({ explicit: handle, config })); @@ -191,6 +197,7 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config, let lastNudgeAt = 0; function nudgeGate(hasPriority) { if (!hasPriority) return { fire: false, why: 'no owner/mention in batch' }; + if (!pollerOwnsWake) return { fire: false, why: `wake path is ${wakePath}, not this poller` }; const now = Date.now(); if (now - lastNudgeAt < nudgeCooldownSec * 1000) { return { fire: false, why: `cooldown ${nudgeCooldownSec}s` }; @@ -214,6 +221,11 @@ export async function startRoomPoller({ rooms, apiKey, handle, interval, config, console.log(` interval: ${pollInterval}s`); console.log(` notification file: ${notifyFile}`); console.log(` nudge mode: ${nudgeMode}`); + console.log(` wake path: ${wakePath}${pollerOwnsWake ? '' : ' (this poller will not nudge)'}`); + if (!pollerOwnsWake && nudgeMode !== 'none') { + console.error(` WARNING: poller.nudge_mode is '${nudgeMode}' but poller.wake_path is '${wakePath}': ` + + 'the wake belongs to another path, so nudges from this poller are refused. Set nudge_mode to none to silence this.'); + } if (nudgeMode === 'tmux') { console.log(` tmux session: ${session} (optional)`); } else if (nudgeMode === 'command') { diff --git a/test/wake-path.test.mjs b/test/wake-path.test.mjs new file mode 100644 index 0000000..7754f1b --- /dev/null +++ b/test/wake-path.test.mjs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Issue #90 item 4: exactly one wake path. With wake_path set to another +// path the poller delivers the notification but never nudges; with the +// default it nudges (positive control, same stubs). +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync, chmodSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { startRoomPoller } from '../src/team-relay/room-poller.mjs'; + +async function runPoll(dir, pollerOverrides) { + const stubDir = path.join(dir, 'bin'); mkdirSync(stubDir, { recursive: true }); + const stub = path.join(stubDir, 'curl'); + writeFileSync(stub, `#!/bin/sh +case "$*" in *limit=50*) echo "[]";; *) echo '[{"id":"o1","from":"petrus","body":"claudemm are you there","created_at":"2026-09-02T00:00:01Z"}]';; esac +`); + chmodSync(stub, 0o755); + const marker = path.join(dir, 'nudged'); + const savedPath = process.env.PATH; process.env.PATH = `${stubDir}:${savedPath}`; + const logs = []; const origLog = console.log; const origErr = console.error; + console.log = (...a) => logs.push(a.join(' ')); console.error = (...a) => logs.push(a.join(' ')); + let timers; + try { + timers = await startRoomPoller({ + rooms: ['r'], apiKey: 'k', handle: '@t', interval: 3600, + config: { poller: { seen_file: path.join(dir, 'seen'), notification_file: path.join(dir, 'notify'), heartbeat_file: path.join(dir, 'hb'), + nudge_mode: 'command', nudge_command: `touch ${marker}`, ...pollerOverrides }, queue: { path: path.join(dir, 'q.jsonl') } } + }); + } finally { + if (timers?.roomTimer) clearInterval(timers.roomTimer); + if (timers?.dmTimer) clearInterval(timers.dmTimer); + console.log = origLog; console.error = origErr; process.env.PATH = savedPath; + } + return { nudged: existsSync(marker), notified: existsSync(path.join(dir, 'notify')) && /petrus: claudemm are you there/.test(readFileSync(path.join(dir, 'notify'), 'utf8')), log: logs.join('\n') }; +} + +test('wake_path webhook: notification delivered, nudge refused with the reason, warning at start', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'iak-wake-')); + try { + const r = await runPoll(dir, { wake_path: 'webhook' }); + assert.equal(r.notified, true); + assert.equal(r.nudged, false, 'poller must not nudge when the webhook owns the wake'); + assert.match(r.log, /wake path is webhook, not this poller/); + assert.match(r.log, /WARNING: poller.nudge_mode is 'command' but poller.wake_path is 'webhook'/); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('default wake_path: the same owner message nudges (positive control)', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'iak-wake-')); + try { + const r = await runPoll(dir, {}); + assert.equal(r.notified, true); + assert.equal(r.nudged, true, 'control: with the poller owning the wake, the nudge command runs'); + assert.match(r.log, /wake path: nudge/); + } finally { rmSync(dir, { recursive: true, force: true }); } +});