|
1 | 1 | import Foundation |
2 | 2 | import CodeIslandCore |
3 | 3 |
|
| 4 | +/// Outcome of applying a `CursorQuestionSignal` to a session snapshot. |
| 5 | +enum CursorQuestionApplication: Equatable { |
| 6 | + /// Session is now display-waiting on a Cursor-side question. |
| 7 | + /// `fresh` is false when it was already waiting (prompt refresh only). |
| 8 | + case markedWaiting(fresh: Bool) |
| 9 | + /// A previously pending question was superseded; normal flow resumed. |
| 10 | + case clearedWaiting |
| 11 | + /// Signal did not apply (wrong source, subagent transcript, approval in flight, …). |
| 12 | + case ignored |
| 13 | +} |
| 14 | + |
4 | 15 | extension AppState { |
5 | 16 | /// Start watching a session's transcript file for appended lines. Safe to call |
6 | 17 | /// repeatedly with the same (session, path) pair — the tailer reattaches only |
@@ -42,9 +53,113 @@ extension AppState { |
42 | 53 | sessions[sessionId] = session |
43 | 54 | } |
44 | 55 |
|
| 56 | + // Cursor stuck-question recovery (#265): if the transcript already ends |
| 57 | + // with an unanswered AskQuestion (e.g. CodeIsland launched or the session |
| 58 | + // was discovered while Cursor sat on a question), surface the wait now |
| 59 | + // instead of showing an endless "thinking". Recency-gated so an idle card |
| 60 | + // over a long-abandoned transcript doesn't resurrect as waiting. |
| 61 | + if let session = sessions[sessionId], |
| 62 | + session.source == "cursor" || session.source == "cursor-cli", |
| 63 | + CursorSessionFolding.parentConversationId(fromTranscriptPath: path) == sessionId, |
| 64 | + let signal = Self.latestCursorTailQuestion(path: path) { |
| 65 | + let modifiedAt = (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date |
| 66 | + let isRecent = modifiedAt.map { Date().timeIntervalSince($0) < Self.cursorQuestionBackfillMaxAge } ?? false |
| 67 | + let skipStalePending: Bool |
| 68 | + if case .pending = signal, !isRecent { |
| 69 | + skipStalePending = true |
| 70 | + } else { |
| 71 | + skipStalePending = false |
| 72 | + } |
| 73 | + if !skipStalePending, var mutable = sessions[sessionId] { |
| 74 | + if Self.applyCursorQuestionSignal( |
| 75 | + signal, |
| 76 | + to: &mutable, |
| 77 | + sessionId: sessionId, |
| 78 | + transcriptPath: path |
| 79 | + ) != .ignored { |
| 80 | + sessions[sessionId] = mutable |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + |
45 | 85 | transcriptTailer.attach(sessionId: sessionId, filePath: path) |
46 | 86 | } |
47 | 87 |
|
| 88 | + /// Backfill freshness bound for flipping a session into the display-only |
| 89 | + /// question wait from a cold start. Live tail deltas are not age-gated. |
| 90 | + nonisolated static let cursorQuestionBackfillMaxAge: TimeInterval = 30 * 60 |
| 91 | + |
| 92 | + /// Trailing Cursor question state for a whole transcript file, scanned in |
| 93 | + /// bounded chunks (same pattern as `latestCodexTurnStatus`). |
| 94 | + nonisolated static func latestCursorTailQuestion(path: String) -> CursorQuestionSignal? { |
| 95 | + guard let handle = FileHandle(forReadingAtPath: path) else { return nil } |
| 96 | + defer { handle.closeFile() } |
| 97 | + |
| 98 | + handle.seek(toFileOffset: 0) |
| 99 | + let chunkSize = 64 * 1024 |
| 100 | + var pendingFragment = Data() |
| 101 | + var latestSignal: CursorQuestionSignal? |
| 102 | + |
| 103 | + while true { |
| 104 | + let chunk = handle.readData(ofLength: chunkSize) |
| 105 | + if chunk.isEmpty { break } |
| 106 | + |
| 107 | + let result = JSONLTailer.scanLines(pendingFragment + chunk) |
| 108 | + pendingFragment = result.trailingFragment |
| 109 | + if let signal = result.delta.cursorQuestion { |
| 110 | + latestSignal = signal |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + return latestSignal |
| 115 | + } |
| 116 | + |
| 117 | + /// Apply a Cursor trailing-question signal to one session snapshot. |
| 118 | + /// |
| 119 | + /// Pure state transition (no side effects) so both the live tail path and the |
| 120 | + /// attach-time backfill share identical rules, and tests can drive it directly: |
| 121 | + /// - `.pending` flips a **main-agent** Cursor session (transcript parent == |
| 122 | + /// session id, so folded Task/subagent transcripts never qualify) into |
| 123 | + /// `.waitingQuestion` with the question text stored for the card. A real |
| 124 | + /// approval wait is never stomped. |
| 125 | + /// - `.cleared` erases the stored question; if the session was in the |
| 126 | + /// display-only wait it resumes as `.processing` (follow-up hooks or tail |
| 127 | + /// deltas refine from there). |
| 128 | + nonisolated static func applyCursorQuestionSignal( |
| 129 | + _ signal: CursorQuestionSignal, |
| 130 | + to session: inout SessionSnapshot, |
| 131 | + sessionId: String, |
| 132 | + transcriptPath: String? |
| 133 | + ) -> CursorQuestionApplication { |
| 134 | + guard session.source == "cursor" || session.source == "cursor-cli" else { return .ignored } |
| 135 | + |
| 136 | + switch signal { |
| 137 | + case .pending(let prompt): |
| 138 | + guard let transcriptPath, |
| 139 | + CursorSessionFolding.parentConversationId(fromTranscriptPath: transcriptPath) == sessionId else { |
| 140 | + return .ignored |
| 141 | + } |
| 142 | + // An interactive approval outranks the display-only wait. |
| 143 | + guard session.status != .waitingApproval else { return .ignored } |
| 144 | + let fresh = session.status != .waitingQuestion || session.cursorPendingQuestion == nil |
| 145 | + session.status = .waitingQuestion |
| 146 | + session.cursorPendingQuestion = prompt |
| 147 | + session.currentTool = nil |
| 148 | + session.toolDescription = nil |
| 149 | + session.lastActivity = Date() |
| 150 | + return .markedWaiting(fresh: fresh) |
| 151 | + |
| 152 | + case .cleared: |
| 153 | + guard session.cursorPendingQuestion != nil else { return .ignored } |
| 154 | + session.cursorPendingQuestion = nil |
| 155 | + if session.status == .waitingQuestion { |
| 156 | + session.status = .processing |
| 157 | + } |
| 158 | + session.lastActivity = Date() |
| 159 | + return .clearedWaiting |
| 160 | + } |
| 161 | + } |
| 162 | + |
48 | 163 | private nonisolated static func latestCodexTurnStatus(path: String) -> ConversationTurnStatus? { |
49 | 164 | guard let handle = FileHandle(forReadingAtPath: path) else { return nil } |
50 | 165 | defer { handle.closeFile() } |
@@ -121,9 +236,34 @@ extension AppState { |
121 | 236 | mutated = true |
122 | 237 | } |
123 | 238 |
|
| 239 | + // Cursor question tool has no hook channel (#265) — the transcript tail is |
| 240 | + // the only signal that the agent is blocked on (or resumed from) a question |
| 241 | + // answered inside Cursor's own UI. |
| 242 | + var questionStateChanged = false |
| 243 | + if let signal = delta.cursorQuestion { |
| 244 | + let application = Self.applyCursorQuestionSignal( |
| 245 | + signal, |
| 246 | + to: &session, |
| 247 | + sessionId: delta.sessionId, |
| 248 | + transcriptPath: attachedTranscriptPaths[delta.sessionId] |
| 249 | + ) |
| 250 | + if application != .ignored { |
| 251 | + mutated = true |
| 252 | + questionStateChanged = true |
| 253 | + } |
| 254 | + if application == .markedWaiting(fresh: true) { |
| 255 | + SoundManager.shared.handleEvent("PermissionRequest") |
| 256 | + } |
| 257 | + } |
| 258 | + |
124 | 259 | if mutated { |
125 | 260 | session.lastActivity = Date() |
126 | 261 | sessions[delta.sessionId] = session |
127 | 262 | } |
| 263 | + if questionStateChanged { |
| 264 | + // Hooks stay silent while Cursor waits on its question, so nothing |
| 265 | + // else recomputes the aggregated pill/mascot state for this flip. |
| 266 | + refreshDerivedState() |
| 267 | + } |
128 | 268 | } |
129 | 269 | } |
0 commit comments