diff --git a/src/common/seen-ids.mjs b/src/common/seen-ids.mjs index 534640d..56088b5 100644 --- a/src/common/seen-ids.mjs +++ b/src/common/seen-ids.mjs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from 'node:fs'; +import { readFileSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; /** * Shared seen-ID management for all platform pollers. @@ -22,6 +23,8 @@ export function loadSeenIds(path, maxIds = 2000) { // M5 hermes poller did on 2026-09-01 (issue #90, item 1). export function saveSeenIds(path, ids, maxIds = 2000) { const arr = [...ids].slice(-maxIds); + // The watermark now lives under the user's state dir, which may not exist yet. + mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.tmp-${process.pid}`; const fd = openSync(tmp, 'w'); try { diff --git a/src/config.mjs b/src/config.mjs index 64df2a8..ce53951 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -1,9 +1,42 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { homedir } from 'node:os'; + +// Seen-id watermarks record what a poller has ALREADY delivered. They were +// defaulted into /tmp, which macOS clears on boot — so every reboot silently +// reset every watermark and the next poll replayed history as if it were new. +// On 2026-09-13 that dumped 48 messages from seven rooms back to March into one +// agent's inbox, including "get them trading before they close today" from 6 +// March. Noise is the mild failure; an agent acting on a six-month-old +// instruction is the real one. +// +// State that must outlive a reboot belongs under the user's own directory. +// Honours XDG_STATE_HOME where set, falls back to ~/.local/state. +const STATE_DIR = process.env.XDG_STATE_HOME + ? resolve(process.env.XDG_STATE_HOME, 'iak') + : resolve(homedir(), '.local', 'state', 'iak'); + +const stateFile = (name) => { + const target = resolve(STATE_DIR, name); + // One-time adoption: a box upgrading from the /tmp defaults still holds a + // valid watermark there. Copying it over means the upgrade itself does not + // cause the single replay this change exists to prevent. + try { + if (!existsSync(target)) { + const legacy = `/tmp/iak-${name === 'seen-ids.txt' ? 'seen-ids' : name.replace(/\.txt$/, '')}.txt`; + mkdirSync(STATE_DIR, { recursive: true }); + if (existsSync(legacy)) copyFileSync(legacy, target); + } + } catch { + // A read-only or unwritable home is not a reason to fail config load; + // the poller will fall back to an empty watermark and simply be noisy once. + } + return target; +}; + const DEFAULT_CONFIG = { listen: { host: '127.0.0.1', port: 8787 }, queue: { path: './ide-agent-queue.jsonl' }, @@ -13,7 +46,7 @@ const DEFAULT_CONFIG = { rooms: '', handle: '', interval_sec: 30, - seen_file: '/tmp/iak-seen-ids.txt', + seen_file: stateFile('seen-ids.txt'), api_key: '', nudge_mode: 'tmux', nudge_command: '', @@ -26,7 +59,7 @@ const DEFAULT_CONFIG = { enabled: false, handle: '', interval_sec: 30, - seen_file: '/tmp/iak-dm-seen-ids.txt', + seen_file: stateFile('dm-seen-ids.txt'), api_key: '', human_only: false, limit: 100 @@ -36,7 +69,7 @@ const DEFAULT_CONFIG = { rate_limit: { message_interval_sec: 30 }, automation: { rules: [], - seen_file: '/tmp/iak-automation-seen.txt', + seen_file: stateFile('automation-seen.txt'), interval_sec: 30, cooldown_sec: 5, first_match_only: true @@ -45,12 +78,12 @@ const DEFAULT_CONFIG = { moltbook: { posts: [], base_url: 'https://www.moltbook.com' }, github: { repos: [], token: '' }, interval_sec: 120, - seen_file: '/tmp/iak-comment-seen.txt' + seen_file: stateFile('comment-seen.txt') }, discord: { channels: [], interval_sec: 30, - seen_file: '/tmp/iak-discord-seen.txt', + seen_file: stateFile('discord-seen.txt'), self_id: '', skip_bots: false }, diff --git a/src/room-poller.mjs b/src/room-poller.mjs index 94bf95b..05839c2 100644 --- a/src/room-poller.mjs +++ b/src/room-poller.mjs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only -import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; +import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { homedir } from 'node:os'; import { randomUUID } from 'node:crypto'; import { execSync } from 'node:child_process'; import { nudgeTmux, nudgeCommand } from './utils.mjs'; @@ -18,7 +20,12 @@ import { resolveSelfHandle, isSelfSender } from './common/handles.mjs'; * The IDE agent calls `rooms check` to read and clear the notification file. */ -const SEEN_FILE_DEFAULT = '/tmp/iak-seen-ids.txt'; +// Durable by default: /tmp is cleared on boot, which silently resets the +// watermark and replays room history as new. See src/config.mjs. +const SEEN_FILE_DEFAULT = resolve( + process.env.XDG_STATE_HOME ? resolve(process.env.XDG_STATE_HOME, 'iak') : resolve(homedir(), '.local', 'state', 'iak'), + 'seen-ids.txt' +); const NOTIFY_FILE_DEFAULT = '/tmp/iak-new-messages.txt'; function loadSeenIds(path) { @@ -31,6 +38,7 @@ function loadSeenIds(path) { function saveSeenIds(path, ids) { const arr = [...ids].slice(-1000); + mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, arr.join('\n') + '\n'); } diff --git a/test/config.test.mjs b/test/config.test.mjs index f8d923c..6466a30 100644 --- a/test/config.test.mjs +++ b/test/config.test.mjs @@ -76,10 +76,16 @@ describe('config', () => { assert.deepEqual(cfg.poller.rooms, ['thinkoff-development']); assert.equal(cfg.poller.handle, '@CodexMB'); assert.equal(cfg.poller.interval_sec, 30); - assert.equal(cfg.poller.seen_file, '/tmp/iak-seen-ids.txt'); + // Watermarks must OUTLIVE a reboot: /tmp is cleared on boot, which silently + // resets them and replays room history as new (2026-09-13: 48 messages back + // to March, including actionable ones). Assert the property, not a literal + // path, since the state dir varies by home and XDG_STATE_HOME. + assert.ok(!cfg.poller.seen_file.startsWith('/tmp/'), 'seen_file must not live in /tmp'); + assert.ok(cfg.poller.seen_file.endsWith('seen-ids.txt'), cfg.poller.seen_file); assert.equal(cfg.dm_poller.enabled, true); assert.equal(cfg.dm_poller.interval_sec, 30); - assert.equal(cfg.dm_poller.seen_file, '/tmp/iak-dm-seen-ids.txt'); + assert.ok(!cfg.dm_poller.seen_file.startsWith('/tmp/'), 'dm seen_file must not live in /tmp'); + assert.ok(cfg.dm_poller.seen_file.endsWith('dm-seen-ids.txt'), cfg.dm_poller.seen_file); assert.equal(cfg.dm_poller.limit, 100); assert.equal(cfg.background.enabled, true); assert.equal(cfg.background.recent_window_sec, 7200);