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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion config/codex.desktop.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
"heartbeat_file": "/tmp/iak-poller.heartbeat",
"handle": "@CodexMB",
"interval_sec": 60,
"nudge_mode": "command",
"wake_path": "webhook",
"nudge_mode": "none",
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the desktop example on a configured wake path

When a user follows the README's Codex Desktop setup and copies this advertised ready-to-copy example, these settings disable the included codex_gui_nudge.sh command, but the example does not configure or start scripts/codex-webhook-supervisor.sh. Consequently, room and DM messages are written to the notification file without waking the desktop app. Keep this example on command-mode nudging, or include the required webhook receiver setup before selecting webhook.

Useful? React with 👍 / 👎.

"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"
Expand Down
8 changes: 8 additions & 0 deletions docs/AGENT-ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/team-relay/room-poller.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment on lines +183 to +184

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject contradictory nudge wake settings

If wake_path is explicitly "nudge" while nudge_mode is "none", pollerOwnsWake becomes true and startup reports that the poller owns the wake, but triggerNudge always returns false and the agent is never awakened; the mismatch warning only covers the inverse combination. Validate or warn on this combination so the new wake-path declaration cannot silently describe a path that is disabled.

Useful? React with 👍 / 👎.

const nudgeCommandText = config?.poller?.nudge_command || '';
const pollInterval = parsePositiveInt(interval || config?.poller?.interval_sec, 30);
const selfHandle = normalizeHandle(resolveSelfHandle({ explicit: handle, config }));
Expand All @@ -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` };
Expand All @@ -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') {
Expand Down
57 changes: 57 additions & 0 deletions test/wake-path.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }); }
});
Loading