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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

### Fixed
- **Duplicate room-responder voices**: `session-bootstrap.sh` now enforces a single room responder per notification file via a lock file (`<notification file>.responder.lock`). The first session claims the lock and gets the full bootstrap; any later session on the same machine gets PASSIVE instructions (serve the user directly, never answer the room) instead of booting a second copy of the same handle. The lock stores the owner's pid + session id + process start time, so the owner reclaims it across resume/compact, a lock whose owner process died is stolen automatically, and a recycled pid never masquerades as the owner (adversarial review by @codexmb: initial claim is `O_EXCL`-atomic, aliveness uses `ps` so a cross-user EPERM never reads as dead, and every winner re-verifies ownership after a settle window so concurrent starts elect exactly one responder). Disable with `IAK_RESPONDER_LOCK=off`, relocate with `IAK_RESPONDER_LOCK=<path>`. Motivation: on 2026-07-16 a stray `claude rc` session on the Mac mini booted the same bootstrap and posted to the room as a second @claudemm, producing conflicting answers.

## 0.9.0 (2026-06-10)

### Added
Expand Down
132 changes: 130 additions & 2 deletions scripts/session-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,29 @@
# with suppressOutput, so the instructions are injected as session context.
# It never blocks session start: any failure degrades to no output + exit 0.
#
# Single-responder guard: only ONE session per notification file may act as
# the room responder. Without it, every fresh session on the machine (a
# second interactive `claude`, a remote-control shell, a stray automation
# launch) boots the same instructions and answers the room as the same
# handle — duplicate, conflicting posts (bit the fleet on 2026-07-16). The
# first session claims a lock file; later sessions get PASSIVE instructions
# (serve the user directly, stay out of the room). The lock carries the
# owner's pid + session id + process start time: it is reclaimed by the
# same session on resume/compact, and stolen automatically once the owner
# process is dead — start time is the identity check, so a recycled pid
# never masquerades as the owner. Initial claim is O_EXCL-atomic and every
# winner re-verifies after a short settle, so concurrent starts elect
# exactly one responder. Any failure in the lock mechanics degrades to
# ACTIVE (status quo) — a rare duplicate voice beats a machine with no
# room responder at all.
#
# Configuration (first match wins):
# notification file : $IAK_NEW_FILE, else config poller.notification_file,
# else /tmp/iak-new-messages.txt (poller default)
# agent handle : $IAK_HANDLE, else config poller.handle (cosmetic)
# fallback interval : $IAK_BOOTSTRAP_FALLBACK_SEC (default 1500 seconds)
# responder lock : $IAK_RESPONDER_LOCK (path, or "off" to disable),
# else <notification file>.responder.lock
# config file : $IAK_CONFIG_JSON, else ide-agent-kit.json next to the
# repo (scripts/..), else two levels up — which is the
# project root when `ide-agent-kit init` installed this
Expand Down Expand Up @@ -89,17 +107,127 @@ INPUT=$(cat 2>/dev/null || true)
SOURCE=$(printf '%s' "$INPUT" | python3 -c "import json,sys
try: print(json.load(sys.stdin).get('source',''))
except Exception: print('')" 2>/dev/null || true)
SESSION_ID=$(printf '%s' "$INPUT" | python3 -c "import json,sys
try: print(json.load(sys.stdin).get('session_id',''))
except Exception: print('')" 2>/dev/null || true)

# --- single-responder lock -------------------------------------------------
# ROLE ends up ACTIVE (full bootstrap) or PASSIVE (stand down); see header.
LOCK_FILE="${IAK_RESPONDER_LOCK:-${NEWMSG_FILE}.responder.lock}"
ROLE="active"
LOCK_PID=""
LOCK_SID=""
LOCK_PSTART=""
LOCK_HELD=""
SETTLE_SEC="${IAK_RESPONDER_SETTLE_SEC:-0.3}"

# Process start time doubles as an identity check: a recycled pid has a
# different start time, so a dead owner is never mistaken for alive. Using
# ps (not kill -0) also keeps a cross-user owner visible — kill -0 reports
# EPERM there, which must not read as "dead".
proc_lstart() {
[ -n "${1:-}" ] || return 1
case "$1" in (*[!0-9]*|'') return 1;; esac
ps -p "$1" -o lstart= 2>/dev/null | sed 's/^ *//;s/ *$//' | head -1
}

read_lock() {
LOCK_PID=$(sed -n 's/^pid=//p' "$LOCK_FILE" 2>/dev/null | head -1)
LOCK_SID=$(sed -n 's/^sid=//p' "$LOCK_FILE" 2>/dev/null | head -1)
LOCK_PSTART=$(sed -n 's/^pstart=//p' "$LOCK_FILE" 2>/dev/null | head -1)
}

owner_alive() {
local now
now=$(proc_lstart "$LOCK_PID") || return 1
[ -n "$now" ] || return 1
# Locks written before pstart existed carry no identity beyond the pid.
[ -z "$LOCK_PSTART" ] || [ "$now" = "$LOCK_PSTART" ]
}

lock_content() {
printf 'pid=%s\nsid=%s\npstart=%s\nclaimed_at=%s\n' \
"$PPID" "$SESSION_ID" "$(proc_lstart "$PPID" || true)" \
"$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)"
}

claim_atomic() {
# O_CREAT|O_EXCL via noclobber: exactly one concurrent claimer wins.
( set -o noclobber; lock_content > "$LOCK_FILE" ) 2>/dev/null
}

claim_overwrite() {
# Only for refreshing a lock this session already owns.
local tmp="${LOCK_FILE}.$$"
{ lock_content > "$tmp" && mv -f "$tmp" "$LOCK_FILE"; } 2>/dev/null \
|| { rm -f "$tmp" 2>/dev/null; return 1; }
}

lost_claim_race() {
# A failed claim only demotes us if somebody else really holds the
# file; an unwritable directory is a mechanics failure -> fail open.
[ -f "$LOCK_FILE" ] && ROLE="passive"
}

if [ "$LOCK_FILE" != "off" ]; then
if [ -f "$LOCK_FILE" ]; then
read_lock
if [ -n "$SESSION_ID" ] && [ "$LOCK_SID" = "$SESSION_ID" ]; then
claim_overwrite || true # same session resuming: refresh pid
elif [ -z "$SESSION_ID" ] && [ "$LOCK_PID" = "$PPID" ]; then
# pid fallback ONLY when the harness passes no session id
# (e.g. compact re-runs in the same process). With a session
# id present the sid line is the sole reclaim identity —
# sibling hook processes can legitimately share a parent pid.
claim_overwrite || true
elif owner_alive; then
ROLE="passive"
else
rm -f "$LOCK_FILE" 2>/dev/null # owner gone (or pid recycled)
claim_atomic || lost_claim_race
fi
else
claim_atomic || lost_claim_race
fi

if [ "$ROLE" = "active" ]; then
# Post-claim verify: near-simultaneous steals can overwrite each
# other; after a short settle whoever the lock names is the single
# winner, everyone else demotes to PASSIVE (the machine still has
# exactly one responder). An unreadable lock here is a mechanics
# failure and stays ACTIVE.
sleep "$SETTLE_SEC" 2>/dev/null || true
read_lock
if [ -n "$LOCK_PID" ] && [ "$LOCK_PID" != "$PPID" ]; then
ROLE="passive"
elif [ -n "$SESSION_ID" ] && [ -n "$LOCK_SID" ] && [ "$LOCK_SID" != "$SESSION_ID" ]; then
ROLE="passive"
else
LOCK_HELD="$LOCK_FILE"
fi
else
read_lock
fi
fi

BACKLOG=""
if [ -s "$NEWMSG_FILE" ]; then
BACKLOG=$(wc -l < "$NEWMSG_FILE" | tr -d ' ')
fi

CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} (injected by SessionStart hook — do this without being asked):
if [ "$ROLE" = "passive" ]; then
CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} — PASSIVE (injected by SessionStart hook): another live session already holds this machine's room-responder role for ${NEWMSG_FILE} (owner pid ${LOCK_PID}${LOCK_SID:+, session ${LOCK_SID}}; lock ${LOCK_FILE}).
1. Do NOT arm a Monitor on ${NEWMSG_FILE}, do NOT run the room loop, and do NOT answer room messages${HANDLE:+ as ${HANDLE}} — two sessions answering as one handle produces duplicate, conflicting posts.
2. Serve the user's direct requests in this session normally; reading the room for context is fine, responding to it is not.
3. Take over ONLY if the user explicitly asks this session to become the room responder: delete ${LOCK_FILE}, then follow the standard bootstrap (Monitor + backlog + '/loop check rooms')."
else
CTX="IAK room-agent bootstrap${HANDLE:+ for ${HANDLE}} (injected by SessionStart hook — do this without being asked):
1. If TaskList shows no running Monitor watching ${NEWMSG_FILE}, arm one now: Monitor(command: \"tail -F -n 0 ${NEWMSG_FILE} 2>/dev/null\", persistent: true). Never arm a duplicate.
2. Backlog: ${BACKLOG:-0} unconsumed line(s) in ${NEWMSG_FILE}. If >0: read the messages (prefer the IAK MCP room_list_new tool), act on them, then ack with the IAK MCP room_ack tool - it removes ONLY what you read, so a message the poller appends mid-ack survives. Only if the IAK MCP tools are unavailable this session, fall back to reading the file directly and clearing it (: > ${NEWMSG_FILE}), re-checking immediately after for lines that arrived during the clear.
3. Operate as the self-paced room loop ('/loop check rooms' semantics): after handling room work, keep a fallback ScheduleWakeup armed (~${FALLBACK_SEC}s) with prompt '/loop check rooms'. The Monitor is the primary wake signal.
4. Session source: ${SOURCE:-unknown}. On 'compact' the Monitor usually survived — verify via TaskList instead of re-arming blindly."
4. Session source: ${SOURCE:-unknown}. On 'compact' the Monitor usually survived — verify via TaskList instead of re-arming blindly.${LOCK_HELD:+
5. You hold the room-responder lock (${LOCK_HELD}): this session is the machine's ONLY room voice. The lock goes stale automatically when this session's process exits — never delete it while active.}"
fi

python3 - "$CTX" <<'PYEOF' 2>/dev/null
import json, sys
Expand Down
155 changes: 154 additions & 1 deletion test/session-bootstrap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { describe, it, afterEach } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { execFile, execFileSync, spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, realpathSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
Expand Down Expand Up @@ -42,6 +42,7 @@ function runHook({ input = '', env = {} } = {}) {
...process.env,
IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json',
IAK_NEW_FILE: '/tmp/iak-nonexistent-new-messages.txt',
IAK_RESPONDER_LOCK: 'off',
...env,
},
});
Expand Down Expand Up @@ -106,6 +107,19 @@ describe('session-bootstrap.sh', () => {
assert.match(ctx, /for @testbot/);
});

it('is disabled entirely with IAK_RESPONDER_LOCK=off (no lock file, no lock text)', () => {
const dir = tempDir();
const newFile = join(dir, 'new.txt');
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-off' }),
env: { IAK_NEW_FILE: newFile },
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /arm one now/);
assert.ok(!ctx.includes('room-responder lock'));
assert.equal(existsSync(`${newFile}.responder.lock`), false);
});

it('env overrides beat the config values', () => {
const dir = tempDir();
const configPath = join(dir, 'ide-agent-kit.json');
Expand All @@ -124,6 +138,145 @@ describe('session-bootstrap.sh', () => {
});
});

describe('single-responder lock', () => {
let sleeper = null;
const startSleeper = () => {
sleeper = spawn('sleep', ['60'], { stdio: 'ignore' });
sleeper.unref();
return sleeper.pid;
};
afterEach(() => {
if (sleeper) {
try { sleeper.kill('SIGKILL'); } catch { /* already gone */ }
sleeper = null;
}
});

const lockEnv = (dir) => {
// Undo runHook's 'off' default: use the real derived-path behavior.
return { IAK_NEW_FILE: join(dir, 'new.txt'), IAK_RESPONDER_LOCK: `${join(dir, 'new.txt')}.responder.lock` };
};

it('first session claims the lock and gets the ACTIVE bootstrap', () => {
const dir = tempDir();
const env = lockEnv(dir);
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-a' }),
env,
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /arm one now/);
assert.match(ctx, /You hold the room-responder lock/);
const lock = readFileSync(env.IAK_RESPONDER_LOCK, 'utf8');
assert.match(lock, /^pid=\d+$/m);
assert.match(lock, /^sid=sess-a$/m);
});

it('derives the lock path from the notification file when not set explicitly', () => {
const dir = tempDir();
const newFile = join(dir, 'new.txt');
const env = { ...process.env, IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json', IAK_NEW_FILE: newFile };
delete env.IAK_RESPONDER_LOCK;
execFileSync('bash', [bootstrapScript], { input: '{"session_id":"sess-d"}', encoding: 'utf8', env });
assert.equal(existsSync(`${newFile}.responder.lock`), true);
});

it('second session goes PASSIVE while the owner process is alive', () => {
const dir = tempDir();
const env = lockEnv(dir);
const ownerPid = startSleeper();
writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${ownerPid}\nsid=sess-owner\n`);
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-b' }),
env,
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /PASSIVE/);
assert.match(ctx, /Do NOT arm a Monitor/);
assert.ok(!ctx.includes('arm one now'));
// lock untouched: still owned by the live owner
assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), new RegExp(`^pid=${ownerPid}$`, 'm'));
});

it('steals a stale lock whose owner process is dead', () => {
const dir = tempDir();
const env = lockEnv(dir);
const dead = spawnSync('true');
writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${dead.pid}\nsid=sess-dead\n`);
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-c' }),
env,
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /arm one now/);
assert.match(ctx, /You hold the room-responder lock/);
assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-c$/m);
});

it('the same session id reclaims its lock on resume even under a live foreign pid', () => {
const dir = tempDir();
const env = lockEnv(dir);
const ownerPid = startSleeper();
writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${ownerPid}\nsid=sess-same\n`);
const payload = runHook({
input: JSON.stringify({ source: 'resume', session_id: 'sess-same' }),
env,
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /arm one now/);
const lock = readFileSync(env.IAK_RESPONDER_LOCK, 'utf8');
assert.match(lock, /^sid=sess-same$/m);
assert.ok(!new RegExp(`^pid=${ownerPid}$`, 'm').test(lock), 'pid refreshed to the resuming session');
});

it('steals a lock whose pid was recycled by an unrelated process (pstart mismatch)', () => {
const dir = tempDir();
const env = lockEnv(dir);
const recycled = startSleeper(); // alive, but not the recorded owner:
writeFileSync(env.IAK_RESPONDER_LOCK, `pid=${recycled}\nsid=sess-old\npstart=Thu Jan 1 00:00:00 1970\n`);
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-f' }),
env,
});
const ctx = payload.hookSpecificOutput.additionalContext;
assert.match(ctx, /You hold the room-responder lock/);
assert.match(readFileSync(env.IAK_RESPONDER_LOCK, 'utf8'), /^sid=sess-f$/m);
});

it('elects exactly one ACTIVE responder among concurrent fresh claims', async () => {
const dir = tempDir();
const env = lockEnv(dir);
const runs = await Promise.all(Array.from({ length: 6 }, (_, i) =>
new Promise((resolvePromise, reject) => {
execFile('bash', [bootstrapScript], {
encoding: 'utf8',
env: {
...process.env,
IAK_CONFIG_JSON: '/tmp/iak-nonexistent-config.json',
...env,
},
}, (err, stdout) => err ? reject(err) : resolvePromise(stdout))
.stdin.end(JSON.stringify({ source: 'startup', session_id: `sess-par-${i}` }));
})
));
const actives = runs.filter((out) =>
JSON.parse(out).hookSpecificOutput.additionalContext.includes('You hold the room-responder lock'));
assert.equal(actives.length, 1, `expected exactly 1 ACTIVE, got ${actives.length}`);
});

it('a corrupt lock file with a live-pid line still fails safe to PASSIVE, not a crash', () => {
const dir = tempDir();
const env = lockEnv(dir);
const ownerPid = startSleeper();
writeFileSync(env.IAK_RESPONDER_LOCK, `garbage line\npid=${ownerPid}\n%%%\n`);
const payload = runHook({
input: JSON.stringify({ source: 'startup', session_id: 'sess-e' }),
env,
});
assert.match(payload.hookSpecificOutput.additionalContext, /PASSIVE/);
});
});

describe('claude-settings hook merge', () => {
it('adds the hook while preserving unrelated hooks and settings', () => {
const settings = {
Expand Down
Loading