diff --git a/src/status-watcher.js b/src/status-watcher.js index 8ed1732..1294eda 100644 --- a/src/status-watcher.js +++ b/src/status-watcher.js @@ -140,11 +140,15 @@ class StatusWatcher extends EventEmitter { if (state.idleTimer) clearTimeout(state.idleTimer); if (state._filePollTimer) clearInterval(state._filePollTimer); - this.fileToSession.delete(state.transcriptFile); + + // Keep fileToSession mapping alive so fs.watch still fires for this file. + // Mark it as a "ghost" — changes trigger 'session-file-changed' so the + // session-monitor can immediately re-detect without waiting for its poll. + this.fileToSession.set(state.transcriptFile, '\x00ghost:' + sessionKey); this.sessions.delete(sessionKey); if (this.logger) { - this.logger.debug({ sessionKey }, 'Session removed from watcher'); + this.logger.debug({ sessionKey }, 'Session removed from watcher (ghost watch active)'); } } @@ -222,6 +226,18 @@ class StatusWatcher extends EventEmitter { const sessionKey = this.fileToSession.get(fullPath); if (!sessionKey) return; + // Ghost watch: file changed for a completed session — signal immediate re-detection + if (sessionKey.startsWith('\x00ghost:')) { + const originalKey = sessionKey.slice(7); + // Remove ghost so we don't fire repeatedly + this.fileToSession.delete(fullPath); + if (this.logger) { + this.logger.info({ sessionKey: originalKey }, 'fs.watch: file change on completed session — triggering reactivation'); + } + this.emit('session-reactivate', originalKey); + return; + } + const state = this.sessions.get(sessionKey); if (!state) return; diff --git a/src/watcher-manager.js b/src/watcher-manager.js index 6cecda9..46a9c4a 100644 --- a/src/watcher-manager.js +++ b/src/watcher-manager.js @@ -326,11 +326,23 @@ async function startDaemon() { }); globalMetrics.activeSessions = activeBoxes.size; - // Register in watcher + // Register in watcher. + // For reactivated sessions (completedBoxes had an entry), always start from + // current file size so we only stream NEW content from this turn forward. + // Using a stale savedOffset from a prior run would skip past the current turn + // (file grew since last offset was saved) and show nothing. const savedState = savedOffsets[sessionKey]; // eslint-disable-line security/detect-object-injection - const initialState = savedState - ? { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId } - : { agentId }; + let initialState; + if (completed) { + // Reactivation: start fresh from current file position (this turn only) + initialState = { agentId }; + } else if (savedState) { + // Restart recovery: resume from saved offset + initialState = { lastOffset: savedState.lastOffset, startTime: savedState.startTime, agentId }; + } else { + // New session: start from current file position + initialState = { agentId }; + } watcher.addSession(sessionKey, transcriptFile, initialState); // Process any pending sub-agents that were waiting for this parent @@ -353,6 +365,16 @@ async function startDaemon() { logger.debug({ sessionKey }, 'Session removed from sessions.json'); }); + // ---- Ghost reactivation (from watcher fs.watch on completed session file) ---- + // Fires immediately when the transcript file changes after a session completes. + // Clears the completedSessions cooldown so the next monitor poll re-detects instantly. + watcher.on('session-reactivate', (sessionKey) => { + logger.info({ sessionKey }, 'Ghost watch triggered reactivation — clearing completed cooldown'); + monitor.clearCompleted(sessionKey); + // Force an immediate poll so the session is re-added without waiting 2s + monitor.pollNow(); + }); + // ---- Session Update (from watcher) ---- watcher.on('session-update', (sessionKey, state) => { const box = activeBoxes.get(sessionKey);