diff --git a/src/session-monitor.js b/src/session-monitor.js index f5c50f1..41ff3fd 100644 --- a/src/session-monitor.js +++ b/src/session-monitor.js @@ -48,6 +48,9 @@ class SessionMonitor extends EventEmitter { this._knownSessions = new Map(); // Set — sessions that were skipped as stale; re-check on next poll this._staleSessions = new Set(); + // Map — sessions that completed idle; suppressed from re-detection + // until the transcript file stops being written to (checked on each poll). + this._completedSessions = new Map(); // Cache: "user:XXXX" -> channelId (resolved DM channels) this._dmChannelCache = new Map(); this._pollTimer = null; @@ -80,10 +83,29 @@ class SessionMonitor extends EventEmitter { /** * Remove a session from known sessions so it can be re-detected on next poll. * Called when the watcher marks a session as idle/done. + * + * The session is placed in a cooldown set (_completedSessions). It will only be + * re-emitted as 'session-added' once the transcript file goes stale (>5 min no + * writes), preventing the complete→reactivate loop that occurs when the gateway + * keeps appending events to a session file after the agent finishes its turn. + * * @param {string} sessionKey */ forgetSession(sessionKey) { this._knownSessions.delete(sessionKey); + // Mark as completed — suppress re-detection while transcript is still active. + // The stale check in _handleNewSession will naturally unblock re-detection + // once the file stops being modified (>5 min gap). + this._completedSessions.set(sessionKey, Date.now()); + } + + /** + * Explicitly clear a session from the completed cooldown, allowing immediate re-detection. + * Use this when a new gateway session replaces an old one with the same key. + * @param {string} sessionKey + */ + clearCompleted(sessionKey) { + this._completedSessions.delete(sessionKey); } /** @@ -350,6 +372,25 @@ class SessionMonitor extends EventEmitter { // Detect new or previously-stale sessions for (const [sessionKey, entry] of currentSessions) { if (!this._knownSessions.has(sessionKey) || this._staleSessions.has(sessionKey)) { + // Skip sessions in completed cooldown — only allow re-detection once the + // transcript has gone stale (file not modified in >5 min). This prevents + // the reactivation loop caused by the gateway appending bookkeeping events + // to the JSONL after a session's agent turn finishes. + if (this._completedSessions.has(sessionKey)) { + const completedAt = this._completedSessions.get(sessionKey); + // Re-activate if sessions.json shows a newer updatedAt than when we + // marked the session complete. This is the infrastructure-level signal + // that the gateway started a new turn — no AI involvement, pure timestamp + // comparison between two on-disk data sources. + const sessionUpdatedAt = entry.updatedAt || 0; + if (sessionUpdatedAt > completedAt) { + // New turn started after completion — reactivate + this._completedSessions.delete(sessionKey); + } else { + // No new turn yet — suppress re-detection + continue; + } + } this._onSessionAdded(entry); } } @@ -359,6 +400,7 @@ class SessionMonitor extends EventEmitter { if (!currentSessions.has(sessionKey)) { this._onSessionRemoved(sessionKey); this._staleSessions.delete(sessionKey); + this._completedSessions.delete(sessionKey); } }