|
| 1 | +/** |
| 2 | + * In-memory notification queue for server → TUI push. |
| 3 | + * |
| 4 | + * Also tracks whether a TUI client is actively connected (polling). |
| 5 | + * The server plugin cannot use `process.env.OPENCODE_CLIENT` to detect TUI |
| 6 | + * because the server runs in a separate process from the TUI client. |
| 7 | + */ |
| 8 | + |
| 9 | +export interface RpcNotification { |
| 10 | + id: number; |
| 11 | + type: string; |
| 12 | + payload: Record<string, unknown>; |
| 13 | + sessionId?: string; |
| 14 | +} |
| 15 | + |
| 16 | +let queue: RpcNotification[] = []; |
| 17 | +let nextNotificationId = 1; |
| 18 | +// Timestamp of last drain — used to detect if a TUI is actively polling. |
| 19 | +// The TUI polls every 500ms; we consider it connected if it polled within |
| 20 | +// the last 3 seconds (6× the poll interval, tolerates transient delays). |
| 21 | +// |
| 22 | +// PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on |
| 23 | +// session A plus an OpenCode Desktop opened on session B for the same project, |
| 24 | +// whose newer RPC server this TUI's port discovery then selects). The TUI |
| 25 | +// poller drains with ITS active session id, so a session is "TUI-connected" |
| 26 | +// only if a TUI recently drained FOR THAT session. A process-global timestamp |
| 27 | +// would make session B's producers (`/aft-status`, configure warnings, etc.) |
| 28 | +// take the TUI-dialog path because session A's TUI is polling — queuing a |
| 29 | +// B-scoped dialog action that A's poller correctly refuses to show, so B's |
| 30 | +// notice is lost (it also suppressed B's non-TUI fallback). Tracking drains per |
| 31 | +// session routes each producer to the right delivery path. |
| 32 | +const lastDrainAtBySession = new Map<string, number>(); |
| 33 | +let lastDrainAtAny = 0; |
| 34 | +const TUI_CONNECTED_WINDOW_MS = 3_000; |
| 35 | + |
| 36 | +/** Push a notification for the TUI to pick up via polling. */ |
| 37 | +export function pushNotification( |
| 38 | + type: string, |
| 39 | + payload: Record<string, unknown>, |
| 40 | + sessionId?: string, |
| 41 | +): void { |
| 42 | + queue.push({ id: nextNotificationId++, type, payload, sessionId }); |
| 43 | + // Cap queue size to prevent unbounded growth if a TUI is not draining. |
| 44 | + // Session-fair eviction: a naive `slice(-50)` drops the globally-oldest |
| 45 | + // items, so a noisy session could evict ANOTHER session's single unseen |
| 46 | + // notification. Instead, always retain each session's newest item, then |
| 47 | + // fill the rest of the budget with the newest overall — no session can |
| 48 | + // starve another's pending dialog out of the window. |
| 49 | + if (queue.length > 100) { |
| 50 | + const newestPerSession = new Map<string | undefined, number>(); |
| 51 | + for (const notification of queue) { |
| 52 | + const previous = newestPerSession.get(notification.sessionId); |
| 53 | + if (previous === undefined || notification.id > previous) { |
| 54 | + newestPerSession.set(notification.sessionId, notification.id); |
| 55 | + } |
| 56 | + } |
| 57 | + const mustKeep = new Set(newestPerSession.values()); |
| 58 | + const byNewest = [...queue].sort((a, b) => b.id - a.id); |
| 59 | + const kept: RpcNotification[] = []; |
| 60 | + for (const notification of byNewest) { |
| 61 | + if (kept.length < 50 || mustKeep.has(notification.id)) kept.push(notification); |
| 62 | + } |
| 63 | + queue = kept.sort((a, b) => a.id - b.id); |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/** Return pending notifications after acking the client's last received id. |
| 68 | + * Updates lastDrainAt so isTuiConnected() reflects recent activity. |
| 69 | + * |
| 70 | + * Session scoping: when `sessionId` is provided, only notifications tagged for |
| 71 | + * that session (or session-less/global ones) are returned and pruned — a |
| 72 | + * notification tagged for a DIFFERENT session is never handed to this client |
| 73 | + * and is never pruned by this client's ack. This matters because the in-memory |
| 74 | + * queue is per-process but a TUI can end up draining a process that also serves |
| 75 | + * OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a |
| 76 | + * newer RPC server that the TUI's port discovery (newest-pid-wins) then selects, |
| 77 | + * so a Desktop-session dialog action would otherwise surface in an unrelated |
| 78 | + * TUI session. Each client also tracks its own `lastReceivedId`, so a global |
| 79 | + * watermark prune would let session A's ack drop session B's still-unseen |
| 80 | + * notification — scoping the prune to the acking session prevents that too. |
| 81 | + * |
| 82 | + * Delivery is at-least-once (non-destructive return + prune-on-ack): a returned |
| 83 | + * notification stays queued until a later call acks it via a higher |
| 84 | + * `lastReceivedId`, so a lost poll response re-delivers on the next poll. */ |
| 85 | +export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] { |
| 86 | + const now = Date.now(); |
| 87 | + lastDrainAtAny = now; |
| 88 | + if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now); |
| 89 | + const matchesClient = (notification: RpcNotification): boolean => |
| 90 | + sessionId === undefined || |
| 91 | + notification.sessionId === undefined || |
| 92 | + notification.sessionId === sessionId; |
| 93 | + if (lastReceivedId > 0) { |
| 94 | + // Prune only notifications THIS client both owns (session-matched) and has |
| 95 | + // acked (id <= lastReceivedId). Other sessions' notifications survive. |
| 96 | + queue = queue.filter( |
| 97 | + (notification) => !(notification.id <= lastReceivedId && matchesClient(notification)), |
| 98 | + ); |
| 99 | + } |
| 100 | + return queue.filter( |
| 101 | + (notification) => notification.id > lastReceivedId && matchesClient(notification), |
| 102 | + ); |
| 103 | +} |
| 104 | + |
| 105 | +/** Whether a TUI client is actively polling for notifications. |
| 106 | + * Returns true only if a TUI has drained within the last 3 seconds. |
| 107 | + * |
| 108 | + * Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT |
| 109 | + * SESSION — this is what producers (`/aft-status`, configure warnings, etc.) |
| 110 | + * must use to decide dialog-vs-message, so a TUI on a different session in the |
| 111 | + * same process does not misroute their delivery. Omit it only for legacy/global |
| 112 | + * callers that genuinely have no session context; they fall back to "any |
| 113 | + * session recently drained" (the pre-per-session behavior). */ |
| 114 | +export function isTuiConnected(sessionId?: string): boolean { |
| 115 | + const now = Date.now(); |
| 116 | + if (sessionId !== undefined) { |
| 117 | + const at = lastDrainAtBySession.get(sessionId) ?? 0; |
| 118 | + return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS; |
| 119 | + } |
| 120 | + return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS; |
| 121 | +} |
0 commit comments