diff --git a/src-tauri/src/acp/session_state.rs b/src-tauri/src/acp/session_state.rs index 602d2cac2a..629d86abd6 100644 --- a/src-tauri/src/acp/session_state.rs +++ b/src-tauri/src/acp/session_state.rs @@ -771,13 +771,16 @@ impl SessionState { meta.as_ref(), images.as_deref(), ); - // Anchor the tool call in `live_message.content` so snapshot - // reload preserves position relative to surrounding text / - // thinking blocks. Idempotent by id: a second ToolCall (or a - // ToolCallUpdate, see below) for the same id must not push a - // duplicate ref. Mirrors text/thinking deltas in lazily - // creating `live_message` if absent. - self.push_tool_call_ref_if_absent(tool_call_id); + // Anchor foreground tool calls in `live_message.content` so a + // snapshot preserves their stream position. Out-of-turn calls + // (for example MCP startup diagnostics while Connected) stay + // in `active_tool_calls` for permission enrichment, but must + // not create a ghost assistant message. A pre-status text + // delta may already have opened the live message, so preserve + // that ordering edge as foreground work. + if self.status == ConnectionStatus::Prompting || self.live_message.is_some() { + self.push_tool_call_ref_if_absent(tool_call_id); + } } AcpEvent::ToolCallUpdate { tool_call_id, @@ -803,11 +806,12 @@ impl SessionState { meta.as_ref(), images.as_deref(), ); - // Defensive: if a ToolCallUpdate arrives before its initial - // ToolCall (unusual ordering / replay), ensure the ref block - // still gets anchored. Idempotent so the normal-flow case is - // a no-op here. - self.push_tool_call_ref_if_absent(tool_call_id); + // Same foreground-only guard as ToolCall. An out-of-turn + // update may enrich a permission snapshot, but it cannot + // manufacture a transcript message on its own. + if self.status == ConnectionStatus::Prompting || self.live_message.is_some() { + self.push_tool_call_ref_if_absent(tool_call_id); + } } AcpEvent::PermissionRequest { request_id, @@ -2414,6 +2418,7 @@ mod tests { // A tool call with no trailing text / thinking → `running tool:` prefix. let mut s = fresh_state(); + s.status = ConnectionStatus::Prompting; s.apply_event(&AcpEvent::ToolCall { tool_call_id: "tc-9".into(), title: "grep files".into(), @@ -3861,6 +3866,7 @@ mod tests { #[test] fn tool_call_ref_push_is_idempotent() { let mut s = fresh_state(); + s.status = ConnectionStatus::Prompting; s.apply_event(&tool_call_event("tc-1", "ls")); // Defensive: second ToolCall with the same id (replay/unusual ordering) // must NOT push a duplicate ref block. @@ -3877,6 +3883,7 @@ mod tests { #[test] fn tool_call_update_does_not_duplicate_ref() { let mut s = fresh_state(); + s.status = ConnectionStatus::Prompting; s.apply_event(&tool_call_event("tc-1", "ls")); s.apply_event(&AcpEvent::ToolCallUpdate { tool_call_id: "tc-1".into(), @@ -3902,6 +3909,38 @@ mod tests { ); } + #[test] + fn out_of_turn_tool_call_does_not_create_ghost_live_message() { + let mut s = fresh_state(); + s.status = ConnectionStatus::Connected; + + s.apply_event(&tool_call_event("startup-1", "MCP startup failed")); + assert!(s.active_tool_calls.contains_key("startup-1")); + assert!(s.live_message.is_none()); + + s.apply_event(&AcpEvent::ToolCallUpdate { + tool_call_id: "startup-1".into(), + title: None, + status: Some("failed".into()), + content: Some("server unavailable".into()), + raw_input: None, + raw_output: None, + raw_output_append: None, + locations: None, + meta: None, + images: None, + }); + assert!(s.live_message.is_none()); + + // A separate call from a real prompting turn still anchors normally. + s.status = ConnectionStatus::Prompting; + s.apply_event(&tool_call_event("turn-1", "Read file")); + assert_eq!( + live_block_summary(&s), + vec![("tool_call_ref", "turn-1".to_string())] + ); + } + #[test] fn tool_call_state_carries_locations_and_meta() { let mut s = fresh_state(); diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 90641c2acb..3e72722f05 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -36,6 +36,15 @@ use crate::web::event_bridge::{ IMPORT_SCAN_PROGRESS_EVENT, TABS_CHANGED_EVENT, }; +// Transcript parsers materialize the whole native session before the response +// window is sliced. A single large Codex rollout can therefore consume several +// GiB transiently, and `spawn_blocking` keeps running even after its HTTP caller +// disconnects. Serialize these parses so tab restores/refetches cannot overlap +// multiple copies and drive the server into swap/OOM. Waiters remain ordinary +// cancellable async tasks until they own the permit. +static TRANSCRIPT_PARSE_CONCURRENCY: tokio::sync::Semaphore = + tokio::sync::Semaphore::const_new(1); + #[derive(Default)] pub(crate) struct ListAllConversationsOptions { pub(crate) folder_ids: Option>, @@ -1113,6 +1122,13 @@ pub async fn get_folder_conversation_core( let (mut turns, session_stats, resolved_ext_id, parsed_title, parsed_model, transcript_watermark) = if let Some(ref ext_id) = summary.external_id { + let _parse_permit = TRANSCRIPT_PARSE_CONCURRENCY + .acquire() + .await + .map_err(|error| { + AppCommandError::task_execution_failed("Conversation parser is unavailable") + .with_detail(error.to_string()) + })?; let at = summary.agent_type; let eid = ext_id.clone(); let db_created_at = summary.created_at; diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index f55ee87004..e8a9470046 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -271,6 +271,14 @@ describe("ConversationDetailPanel split-group render model", () => { expect(source).toContain("showActiveFlow={(isSplit || canTileG) && active}") }) + it("keeps hidden session controllers but suspends transcript work", () => { + expect(source).toContain("isVisible={visible}") + expect(source).toContain( + "useConversationDetail(effectiveConversationId, { enabled: isVisible })" + ) + expect(source).toContain("if (!isVisible) return null") + }) + it("gives each split group its own strip and divider overlays only while split", () => { expect(source).toContain("") const handlesIdx = source.indexOf("groupHandles.map((handle) => (") diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 64645c1a61..0bfeeb7e10 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -156,6 +156,10 @@ interface ConversationTabViewProps { agentType: AgentType workingDir?: string isActive: boolean + /** Whether this tab is currently painted (selected, tiled, or split). + * Background tabs keep their connection controller mounted, but must not + * fetch or render the heavyweight transcript subtree. */ + isVisible: boolean /** Drive the composer's flowing active-session border. True only for the * active tab while several sessions are visible (tiled within a group * and/or split across groups) — the places the flow serves as the "which @@ -235,6 +239,7 @@ const ConversationTabView = memo(function ConversationTabView({ agentType, workingDir, isActive, + isVisible, showActiveFlow, reloadSignal, groupId, @@ -461,7 +466,7 @@ const ConversationTabView = memo(function ConversationTabView({ loading: detailLoading, error: detailError, acpLoadError, - } = useConversationDetail(effectiveConversationId) + } = useConversationDetail(effectiveConversationId, { enabled: isVisible }) // Subscribe to only the fields this panel actually reads from its runtime // session — NOT the whole session object. The live-message sink rewrites the @@ -1903,6 +1908,12 @@ const ConversationTabView = memo(function ConversationTabView({ [feedbackSteer] ) + // Keep every tab's connection/lifecycle hooks resident so background agents + // continue running, but do not keep a second copy of the transcript renderer + // (and all of its streaming derivations) alive for every persisted tab. The + // detail stays in the runtime cache, so returning to the tab is immediate. + if (!isVisible) return null + return ( ({ }), })) +function toolUpdate( + overrides: Partial = {} +): ToolCallUpdatePayload { + return { + contextKey: "tab-1", + tool_call_id: "tool-1", + title: null, + fallback_title: "Tool", + fallback_kind: "tool", + status: null, + content: null, + raw_input: null, + raw_output: null, + locations: null, + meta: null, + images: null, + ...overrides, + } +} + +describe("tool-call update memory bounds", () => { + it("coalesces append streams without changing replacement semantics", () => { + const updates = new ToolCallUpdateAccumulator( + toolUpdate({ status: "in_progress" }) + ) + updates.add(toolUpdate({ raw_output: "a", raw_output_append: true })) + const firstAppend = updates.finish() + expect(firstAppend.raw_output).toBe("a") + expect(firstAppend.raw_output_append).toBe(true) + + updates.add(toolUpdate({ raw_output: "b", raw_output_append: true })) + const secondAppend = updates.finish() + expect(secondAppend.raw_output).toBe("ab") + expect(secondAppend.raw_output_append).toBe(true) + + updates.add(toolUpdate({ raw_output: "new", raw_output_append: false })) + updates.add(toolUpdate({ raw_output: " tail", raw_output_append: true })) + const replacementThenAppend = updates.finish() + expect(replacementThenAppend.raw_output).toBe("new tail") + expect(replacementThenAppend.raw_output_append).toBe(false) + }) + + it("bounds both pending output text and retained chunk count", () => { + const updates = new ToolCallUpdateAccumulator( + toolUpdate({ raw_output: "seed", raw_output_append: true }) + ) + updates.add( + toolUpdate({ + raw_output: "x".repeat(210_000), + raw_output_append: true, + }) + ) + const oversized = updates.finish() + expect(oversized.raw_output).toHaveLength(200_000) + + const bounded = boundLiveToolOutputChunks( + Array.from({ length: 65 }, () => "x") + ) + expect(bounded.chunks).toHaveLength(1) + expect(bounded.total).toBe(65) + + const singleOversized = boundLiveToolOutputChunks(["z".repeat(210_000)]) + expect(singleOversized.chunks).toHaveLength(1) + expect(singleOversized.chunks[0]).toHaveLength(200_000) + expect(singleOversized.total).toBe(200_000) + }) +}) + function Probe() { const actions = useAcpActions() const store = useConnectionStore() diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index c6614cba45..e3ae033ead 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -353,6 +353,23 @@ function sameConnectRequest(a: ConnectRequest, b: ConnectRequest) { // ── Reducer actions ── +export interface ToolCallUpdatePayload { + contextKey: string + tool_call_id: string + title: string | null + fallback_title: string + fallback_kind: string + status: string | null + content: string | null + raw_input: string | null + raw_output: string | null + raw_output_append?: boolean + locations: unknown + meta: ToolCallMeta + /** `null` preserves the previously received image list. */ + images: ToolCallImage[] | null +} + type Action = | { type: "CONNECTION_CREATED" @@ -429,46 +446,10 @@ type Action = /** `null` when the wire event omitted the field (no images). */ images: ToolCallImage[] | null } - | { - type: "TOOL_CALL_UPDATE" - contextKey: string - tool_call_id: string - title: string | null - fallback_title: string - fallback_kind: string - status: string | null - content: string | null - raw_input: string | null - raw_output: string | null - raw_output_append?: boolean - locations: unknown - meta: ToolCallMeta - /** - * `null` when the wire event omitted the field — preserve prior images. - * `[]` (empty array) when the agent explicitly cleared images. - * `[a, b]` to replace. - */ - images: ToolCallImage[] | null - } + | ({ type: "TOOL_CALL_UPDATE" } & ToolCallUpdatePayload) | { type: "BATCH_TOOL_CALL_UPDATES" - actions: Array<{ - contextKey: string - tool_call_id: string - title: string | null - fallback_title: string - fallback_kind: string - status: string | null - content: string | null - raw_input: string | null - raw_output: string | null - raw_output_append?: boolean - // eslint-disable-next-line @typescript-eslint/no-explicit-any - locations: any | null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - meta: any | null - images: ToolCallImage[] | null - }> + actions: ToolCallUpdatePayload[] } | { type: "PERMISSION_REQUEST" @@ -637,8 +618,153 @@ type StreamingAction = type ConnectionsMap = Map const MAX_LIVE_TOOL_RAW_OUTPUT_CHARS = 200_000 +const MAX_LIVE_TOOL_RAW_OUTPUT_CHUNKS = 64 +const MAX_PENDING_TOOL_CALL_UPDATES = 512 +const MAX_PENDING_TOOL_RAW_OUTPUT_CHUNKS = 4_096 const MAX_BUFFERED_UNMAPPED_EVENTS_PER_CONNECTION = 64 const MAX_BUFFERED_UNMAPPED_CONNECTIONS = 128 + +function trimLiveToolOutput(text: string): string { + return text.length <= MAX_LIVE_TOOL_RAW_OUTPUT_CHARS + ? text + : text.slice(-MAX_LIVE_TOOL_RAW_OUTPUT_CHARS) +} + +/** + * Mutable, frame-local accumulator for one tool. Keeping output as chunks makes + * each websocket event O(1) instead of repeatedly concatenating an ever larger + * string while the main thread is catching up. `finish` joins at most once per + * animation frame and emits the same append/replace operation the reducer + * would have observed after applying every update in order. + */ +export class ToolCallUpdateAccumulator { + private payload: ToolCallUpdatePayload + private rawOutputChunks: string[] = [] + private rawOutputHead = 0 + private rawOutputChars = 0 + private hasRawOutput = false + private rawOutputAppend: boolean | undefined + + constructor(initial: ToolCallUpdatePayload) { + this.payload = { ...initial, raw_output: null } + this.mergeRawOutput(initial.raw_output, initial.raw_output_append) + } + + add(incoming: ToolCallUpdatePayload): void { + this.payload = { + ...this.payload, + title: incoming.title ?? this.payload.title, + fallback_title: incoming.fallback_title, + fallback_kind: incoming.fallback_kind, + status: incoming.status ?? this.payload.status, + content: incoming.content ?? this.payload.content, + raw_input: incoming.raw_input ?? this.payload.raw_input, + locations: incoming.locations ?? this.payload.locations, + meta: incoming.meta ?? this.payload.meta, + images: incoming.images ?? this.payload.images, + } + this.mergeRawOutput(incoming.raw_output, incoming.raw_output_append) + } + + finish(): ToolCallUpdatePayload { + return { + ...this.payload, + raw_output: this.hasRawOutput + ? this.rawOutputChunks.slice(this.rawOutputHead).join("") + : null, + raw_output_append: this.rawOutputAppend, + } + } + + private mergeRawOutput( + rawOutput: string | null, + append: boolean | undefined + ): void { + if (rawOutput === null) return + + if (!append) { + const bounded = trimLiveToolOutput(rawOutput) + this.rawOutputChunks = [bounded] + this.rawOutputHead = 0 + this.rawOutputChars = bounded.length + this.hasRawOutput = true + this.rawOutputAppend = false + return + } + + if (!this.hasRawOutput) { + this.rawOutputAppend = true + this.hasRawOutput = true + } + // Appending after a replacement must remain one replacement of the old + // reducer value; appending after an append stays an append. + this.rawOutputChunks.push(rawOutput) + this.rawOutputChars += rawOutput.length + + while ( + this.rawOutputChars > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS && + this.rawOutputHead < this.rawOutputChunks.length + ) { + const excess = this.rawOutputChars - MAX_LIVE_TOOL_RAW_OUTPUT_CHARS + const head = this.rawOutputChunks[this.rawOutputHead] + if (head.length <= excess) { + this.rawOutputChars -= head.length + this.rawOutputHead += 1 + } else { + this.rawOutputChunks[this.rawOutputHead] = head.slice(excess) + this.rawOutputChars -= excess + } + } + + const activeChunks = this.rawOutputChunks.length - this.rawOutputHead + if ( + this.rawOutputHead >= MAX_PENDING_TOOL_RAW_OUTPUT_CHUNKS || + activeChunks > MAX_PENDING_TOOL_RAW_OUTPUT_CHUNKS + ) { + this.rawOutputChunks = [ + this.rawOutputChunks.slice(this.rawOutputHead).join(""), + ] + this.rawOutputHead = 0 + } + } +} + +export function boundLiveToolOutputChunks(chunks: string[]): { + chunks: string[] + total: number +} { + let bounded = chunks + let total = bounded.reduce((sum, chunk) => sum + chunk.length, 0) + + if (total > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS) { + let evictCount = 0 + let evictedChars = 0 + while ( + evictCount < bounded.length - 1 && + total - evictedChars > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS + ) { + evictedChars += bounded[evictCount].length + evictCount += 1 + } + if (evictCount > 0) { + bounded = bounded.slice(evictCount) + total -= evictedChars + } + if (bounded.length === 1 && total > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS) { + bounded = [trimLiveToolOutput(bounded[0])] + total = bounded[0].length + } + } + + // A byte cap alone still permits hundreds of thousands of one-character + // chunks, making each immutable append copy an ever-growing array. Collapse + // periodically so append cost stays bounded as well. + if (bounded.length > MAX_LIVE_TOOL_RAW_OUTPUT_CHUNKS) { + bounded = [bounded.join("")] + total = bounded[0].length + } + return { chunks: bounded, total } +} /** * How many times a user-driven `reconnect` will wait for an in-flight * `connect()` on the same key before giving up and rebuilding anyway. Small on @@ -1641,6 +1767,9 @@ function connectionsReducer( case "TOOL_CALL": { const conn = state.get(action.contextKey) if (!conn) return state + const boundedRawOutput = boundLiveToolOutputChunks( + action.raw_output !== null ? [action.raw_output] : [] + ) // Out-of-turn wire tool activity stays OUT of `liveMessage` (the // transcript overlay renders that content — grafting it here recreated // the garbled-timeline bug), but its context is still recorded so a @@ -1656,9 +1785,8 @@ function connectionsReducer( status: action.status, content: action.content, raw_input: action.raw_input, - raw_output_chunks: - action.raw_output !== null ? [action.raw_output] : [], - raw_output_total_bytes: action.raw_output?.length ?? 0, + raw_output_chunks: boundedRawOutput.chunks, + raw_output_total_bytes: boundedRawOutput.total, locations: action.locations, meta: action.meta, images: action.images ?? [], @@ -1688,11 +1816,11 @@ function connectionsReducer( raw_input: action.raw_input ?? block.info.raw_input, raw_output_chunks: action.raw_output !== null - ? [action.raw_output] + ? boundedRawOutput.chunks : block.info.raw_output_chunks, raw_output_total_bytes: action.raw_output !== null - ? action.raw_output.length + ? boundedRawOutput.total : block.info.raw_output_total_bytes, images: action.images !== null ? action.images : block.info.images, @@ -1715,9 +1843,8 @@ function connectionsReducer( status: action.status, content: action.content, raw_input: action.raw_input, - raw_output_chunks: - action.raw_output !== null ? [action.raw_output] : [], - raw_output_total_bytes: action.raw_output?.length ?? 0, + raw_output_chunks: boundedRawOutput.chunks, + raw_output_total_bytes: boundedRawOutput.total, locations: action.locations ?? null, meta: action.meta ?? null, images: action.images ?? [], @@ -1796,9 +1923,9 @@ function connectionsReducer( let newContent: LiveContentBlock[] if (existingIndex === -1) { - const initialChunks = + const initial = boundLiveToolOutputChunks( action.raw_output !== null ? [action.raw_output] : [] - const initialBytes = action.raw_output?.length ?? 0 + ) newContent = [ ...prev.content, { @@ -1809,11 +1936,11 @@ function connectionsReducer( kind: action.fallback_kind, status: action.status ?? - (initialChunks.length > 0 ? "in_progress" : "pending"), + (initial.chunks.length > 0 ? "in_progress" : "pending"), content: action.content, raw_input: action.raw_input, - raw_output_chunks: initialChunks, - raw_output_total_bytes: initialBytes, + raw_output_chunks: initial.chunks, + raw_output_total_bytes: initial.total, locations: action.locations ?? null, meta: action.meta ?? null, images: action.images ?? [], @@ -1831,33 +1958,17 @@ function connectionsReducer( newChunks = block.info.raw_output_chunks newTotalBytes = block.info.raw_output_total_bytes } else if (action.raw_output_append) { - newChunks = [...block.info.raw_output_chunks, action.raw_output] - newTotalBytes = - block.info.raw_output_total_bytes + action.raw_output.length - - // 超限时从头部批量移除 chunks(单次 slice 替代循环 shift) - if ( - newTotalBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS && - newChunks.length > 1 - ) { - let evictCount = 0 - let evictedBytes = 0 - while ( - evictCount < newChunks.length - 1 && - newTotalBytes - evictedBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS - ) { - evictedBytes += newChunks[evictCount].length - evictCount++ - } - if (evictCount > 0) { - newChunks = newChunks.slice(evictCount) - newTotalBytes -= evictedBytes - } - } + const bounded = boundLiveToolOutputChunks([ + ...block.info.raw_output_chunks, + action.raw_output, + ]) + newChunks = bounded.chunks + newTotalBytes = bounded.total } else { // 非 append 模式(替换) - newChunks = [action.raw_output] - newTotalBytes = action.raw_output.length + const bounded = boundLiveToolOutputChunks([action.raw_output]) + newChunks = bounded.chunks + newTotalBytes = bounded.total } newContent = [ @@ -3294,33 +3405,21 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { ) // ── RAF batching for tool_call_update events ── - const pendingToolCallUpdates = useRef< - Array<{ - contextKey: string - tool_call_id: string - title: string | null - fallback_title: string - fallback_kind: string - status: string | null - content: string | null - raw_input: string | null - raw_output: string | null - raw_output_append?: boolean - locations: unknown - meta: ToolCallMeta - images: ToolCallImage[] | null - }> - >([]) + const pendingToolCallUpdates = useRef( + new Map() + ) const toolCallUpdateRafId = useRef(null) const flushPendingToolCallUpdates = useCallback(() => { - if (pendingToolCallUpdates.current.length === 0) return + if (pendingToolCallUpdates.current.size === 0) return if (toolCallUpdateRafId.current !== null) { cancelAnimationFrame(toolCallUpdateRafId.current) toolCallUpdateRafId.current = null } - const batch = pendingToolCallUpdates.current - pendingToolCallUpdates.current = [] + const batch = Array.from(pendingToolCallUpdates.current.values(), (item) => + item.finish() + ) + pendingToolCallUpdates.current.clear() dispatch({ type: "BATCH_TOOL_CALL_UPDATES", actions: batch }) }, [dispatch]) @@ -3449,9 +3548,9 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { images: e.images ?? null, }) break - case "tool_call_update": + case "tool_call_update": { flushStreamingQueue() - pendingToolCallUpdates.current.push({ + const update: ToolCallUpdatePayload = { contextKey, tool_call_id: e.tool_call_id, title: e.title, @@ -3465,9 +3564,26 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { locations: e.locations ?? null, meta: (e.meta as ToolCallMeta) ?? null, images: e.images ?? null, - }) + } + const updateKey = `${contextKey}\u0000${e.tool_call_id}` + const accumulator = pendingToolCallUpdates.current.get(updateKey) + if ( + accumulator === undefined && + pendingToolCallUpdates.current.size >= MAX_PENDING_TOOL_CALL_UPDATES + ) { + flushPendingToolCallUpdates() + } + if (accumulator) { + accumulator.add(update) + } else { + pendingToolCallUpdates.current.set( + updateKey, + new ToolCallUpdateAccumulator(update) + ) + } scheduleToolCallUpdateFlush() break + } case "permission_resolved": // Backend signals a permission was answered (this window's local // respondPermission, a sibling window, a server-mode peer, or