From 24c44cfcc885fdb770fa9a4741718d5ee3564f81 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 10:10:55 -0300 Subject: [PATCH 1/5] fix: synchronize tabManager access from socket threads; cap line buffer; sync notification.clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-08-20 H1/M7/M9. @MainActor on TerminalController is advisory under Swift 5 mode, so every off-main tabManager access must go through v2MainSync — seven read sites and one write placement (v2WindowCreate's setActiveTabManager ran on the connection thread after its lookup closure returned) did not. notification.clear replied ok before its async mutation ran, so clear-then-list raced. The per-connection line buffer had no size cap; one newline-less write could grow it without bound — now closes with payload_too_large at 8 MiB. --- Sources/TerminalController+Debug.swift | 16 +++++----- Sources/TerminalController+Notification.swift | 4 +-- Sources/TerminalController+Window.swift | 31 +++++++++++++------ Sources/TerminalController.swift | 13 +++++++- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/Sources/TerminalController+Debug.swift b/Sources/TerminalController+Debug.swift index 0ac18b0c..48b70139 100644 --- a/Sources/TerminalController+Debug.swift +++ b/Sources/TerminalController+Debug.swift @@ -669,7 +669,7 @@ extension TerminalController { } private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines) var result = "ERROR: No tab selected" @@ -984,7 +984,7 @@ extension TerminalController { } private func isTerminalFocused(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !panelArg.isEmpty else { return "ERROR: Usage: is_terminal_focused " } @@ -1031,7 +1031,7 @@ extension TerminalController { } private func renderStats(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1253,7 +1253,7 @@ extension TerminalController { #if DEBUG private func focusFromNotification(_ args: String) -> String { - guard let tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) let tabArg = parts.first ?? "" @@ -1278,7 +1278,7 @@ extension TerminalController { } private func flashCount(_ args: String) -> String { - guard let tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" } @@ -1320,7 +1320,7 @@ extension TerminalController { private static var panelSnapshots: [UUID: PanelSnapshotState] = [:] private func panelSnapshotReset(_ args: String) -> String { - guard let tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !panelArg.isEmpty else { return "ERROR: Usage: panel_snapshot_reset " } @@ -1410,7 +1410,7 @@ extension TerminalController { } private func panelSnapshot(_ args: String) -> String { - guard let tabManager = tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "ERROR: Usage: panel_snapshot [label]" } @@ -1520,7 +1520,7 @@ extension TerminalController { } private func layoutDebug() -> String { - guard let tabManager else { return "ERROR: TabManager not available" } + guard let tabManager = v2MainSync({ self.tabManager }) else { return "ERROR: TabManager not available" } var result = "ERROR: No tab selected" DispatchQueue.main.sync { diff --git a/Sources/TerminalController+Notification.swift b/Sources/TerminalController+Notification.swift index 7d0655f1..c98618a9 100644 --- a/Sources/TerminalController+Notification.swift +++ b/Sources/TerminalController+Notification.swift @@ -153,12 +153,12 @@ extension TerminalController { /// to that workspace's notifications only; without one, clears all notifications globally. func v2NotificationClear(params: [String: Any]) -> V2CallResult { if let workspaceId = v2UUID(params, "workspace_id") { - DispatchQueue.main.async { + v2MainSync { TerminalNotificationStore.shared.clearNotifications(forTabId: workspaceId) } return .ok(["workspace_id": workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)]) } - DispatchQueue.main.async { + v2MainSync { TerminalNotificationStore.shared.clearAll() } return .ok([:]) diff --git a/Sources/TerminalController+Window.swift b/Sources/TerminalController+Window.swift index 0fbc6bb9..5e177903 100644 --- a/Sources/TerminalController+Window.swift +++ b/Sources/TerminalController+Window.swift @@ -26,16 +26,27 @@ extension TerminalController { } func v2WindowCurrent(params _: [String: Any]) -> V2CallResult { - guard let tabManager else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + enum Resolution { + case unavailable + case notFound + case found(UUID) + } + let resolution: Resolution = v2MainSync { + guard let tabManager = self.tabManager else { return .unavailable } + guard let windowId = self.v2ResolveWindowId(tabManager: tabManager) else { return .notFound } + return .found(windowId) } - guard let windowId = v2ResolveWindowId(tabManager: tabManager) else { + switch resolution { + case .unavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: return .err(code: "not_found", message: "Current window not found", data: nil) + case .found(let windowId): + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId) + ]) } - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) } func v2WindowFocus(params: [String: Any]) -> V2CallResult { @@ -59,8 +70,10 @@ extension TerminalController { return .err(code: "internal_error", message: "Failed to create window", data: nil) } // The new window should become key, but setActiveTabManager defensively. - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { - setActiveTabManager(tm) + v2MainSync { + if let tm = AppDelegate.shared?.tabManagerFor(windowId: windowId) { + self.setActiveTabManager(tm) + } } return .ok([ "window_id": windowId.uuidString, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index bc2de09d..524a91a0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1485,6 +1485,10 @@ class TerminalController { } } + // Caps the unterminated (no-newline-yet) line buffer so a client that never sends a + // newline can't grow `pending` without bound. + let maxPendingLineBytes = 8 * 1024 * 1024 + var buffer = [UInt8](repeating: 0, count: 4096) var pending = "" var authenticated = false @@ -1503,6 +1507,13 @@ class TerminalController { let chunk = String(bytes: buffer[0.. maxPendingLineBytes { + dilog("socket.conn", "pending line buffer exceeded \(maxPendingLineBytes) bytes; closing") + connection.writeLine("{\"ok\":false,\"error\":{\"code\":\"payload_too_large\"}}") + closeReason = "payload_too_large" + break + } + while let newlineIndex = pending.firstIndex(of: "\n") { let line = String(pending[.. UUID? { From e01759c7f4c785e9cec6f951a7c4e069dcf5f809 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 10:10:55 -0300 Subject: [PATCH 2/5] fix: tmux session targets fail loudly; hooks fail open on transport errors; honest CLI errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-08-20 H3/M2/L1/L2. A session-qualified tmux target (sess:2) silently discarded the session name and resolved against the flat workspace pool — now tries the full token as a title, then the session name for window 0/1, then errors explaining programa has no tmux sessions. Agent hooks failed CLOSED on session-start/prompt-submit for the same transport errors teardown tolerated — a quit Programa blocked the next prompt; all three agents now fail open consistently. focus/close-window no longer report every failure as 'Window not found', and layout apply sends the caller's cwd as its help text promised. --- CLI/CLI+Hooks.swift | 440 ++++++++++++++++++++++----------------- CLI/CLI+TmuxCompat.swift | 33 ++- CLI/programa.swift | 9 +- 3 files changed, 289 insertions(+), 193 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 9479f856..4a789b6a 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -235,47 +235,57 @@ extension ProgramaCLI { switch subcommand { case "session-start", "active": - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: nil, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: nil, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - let claudePid: Int? = { - guard let raw = ProcessInfo.processInfo.environment["PROGRAMA_CLAUDE_PID"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - let pid = Int(raw), - pid > 0 else { - return nil - } - return pid - }() - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( - sessionId: sessionId, + // Wrapped in do/catch like "stop"/"idle": a Programa quit mid-session + // must not block the agent's next hook invocation on a dead socket. + do { + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: nil, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: nil, + fallback: surfaceArg, workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: parsedInput.cwd, - pid: claudePid + client: client ) + let claudePid: Int? = { + guard let raw = ProcessInfo.processInfo.environment["PROGRAMA_CLAUDE_PID"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let pid = Int(raw), + pid > 0 else { + return nil + } + return pid + }() + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: claudePid + ) + } + // Register PID for stale-session detection and OSC suppression, + // but don't set a visible status. "Running" only appears when the + // user submits a prompt (UserPromptSubmit) or Claude starts working + // (PreToolUse). + if let claudePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": "claude_code", + "pid": claudePid, + ]) + } + print("OK") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("OK") + return + } + throw error } - // Register PID for stale-session detection and OSC suppression, - // but don't set a visible status. "Running" only appears when the - // user submits a prompt (UserPromptSubmit) or Claude starts working - // (PreToolUse). - if let claudePid { - _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ - "workspace_id": workspaceId, - "key": "claude_code", - "pid": claudePid, - ]) - } - print("OK") case "stop", "idle": do { @@ -338,28 +348,38 @@ extension ProgramaCLI { } case "prompt-submit": - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: mappedSession?.surfaceId, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - _ = try client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) - try setClaudeStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) - reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: .working) - print("OK") + // Wrapped in do/catch like "stop"/"idle": a Programa quit mid-session + // must not block the agent's next prompt on a dead socket. + do { + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + _ = try client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + try setClaudeStatus( + client: client, + workspaceId: workspaceId, + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" + ) + reportAgentState(client: client, workspaceId: workspaceId, surfaceId: surfaceId, state: .working) + print("OK") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("OK") + return + } + throw error + } case "notification", "notify": var summary = summarizeClaudeHookNotification(parsedInput: parsedInput) @@ -701,6 +721,10 @@ extension ProgramaCLI { return trimmed } + // NOTE: despite the "Teardown" name, this is also reused by session-start/active + // and prompt-submit across all three agent hooks (claude/codex/opencode) to fail + // open on the same transport-failure fragments — a Programa quit mid-session must + // not block an agent's next hook invocation. Kept as-is (no rename) per scope. private func shouldIgnoreClaudeHookTeardownError(_ error: Error) -> Bool { let message = String(describing: error).lowercased() let benignFragments = [ @@ -2338,78 +2362,98 @@ extension ProgramaCLI { switch subcommand { case "session-start": - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: nil, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: nil, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId) - let codexPid = inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( - sessionId: sessionId, + // Wrapped in do/catch like "stop": a Programa quit mid-session must + // not block the agent's next hook invocation on a dead socket. + do { + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: nil, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: nil, + fallback: surfaceArg, workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: parsedInput.cwd, - pid: codexPid + client: client ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId) + let codexPid = inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: codexPid + ) + } + if let codexPid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": codexPid, + ]) + } + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error } - if let codexPid { - _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ - "workspace_id": workspaceId, - "key": agentPIDKey, - "pid": codexPid, - ]) - } - print("{}") case "prompt-submit": - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - ) - let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) - let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId, let mappedSession { - try? sessionStore.upsert( - sessionId: sessionId, + // Wrapped in do/catch like "stop": a Programa quit mid-session must + // not block the agent's next prompt on a dead socket. + do { + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId, let mappedSession { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: mappedSession.surfaceId, + cwd: parsedInput.cwd ?? mappedSession.cwd, + pid: codexPid + ) + } + if let codexPid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": codexPid, + ]) + } + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + try setCodexStatus( + client: client, workspaceId: workspaceId, - surfaceId: mappedSession.surfaceId, - cwd: parsedInput.cwd ?? mappedSession.cwd, - pid: codexPid + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" ) + let promptSubmitSurfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + reportAgentState(client: client, workspaceId: workspaceId, surfaceId: promptSubmitSurfaceId, state: .working) + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error } - if let codexPid { - _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ - "workspace_id": workspaceId, - "key": agentPIDKey, - "pid": codexPid, - ]) - } - _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) - try setCodexStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) - let promptSubmitSurfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: mappedSession?.surfaceId, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - reportAgentState(client: client, workspaceId: workspaceId, surfaceId: promptSubmitSurfaceId, state: .working) - print("{}") case "stop": do { @@ -2795,78 +2839,98 @@ extension ProgramaCLI { switch subcommand { case "session-start": - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: nil, - fallback: workspaceArg, - client: client - ) - let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: nil, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId) - let opencodePid = inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId { - try? sessionStore.upsert( - sessionId: sessionId, + // Wrapped in do/catch like "stop": a Programa quit mid-session must + // not block the agent's next hook invocation on a dead socket. + do { + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: nil, + fallback: workspaceArg, + client: client + ) + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: nil, + fallback: surfaceArg, workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: parsedInput.cwd, - pid: opencodePid + client: client ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId) + let opencodePid = inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: opencodePid + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error } - if let opencodePid { - _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ - "workspace_id": workspaceId, - "key": agentPIDKey, - "pid": opencodePid, - ]) - } - print("{}") case "prompt-submit": - let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } - let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( - preferred: mappedSession?.workspaceId, - fallback: workspaceArg, - client: client - ) - let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) - let opencodePid = mappedSession?.pid ?? inferredCodexAgentPID() - if let sessionId = parsedInput.sessionId, let mappedSession { - try? sessionStore.upsert( - sessionId: sessionId, + // Wrapped in do/catch like "stop": a Programa quit mid-session must + // not block the agent's next prompt on a dead socket. + do { + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let agentPIDKey = opencodeAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let opencodePid = mappedSession?.pid ?? inferredCodexAgentPID() + if let sessionId = parsedInput.sessionId, let mappedSession { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: mappedSession.surfaceId, + cwd: parsedInput.cwd ?? mappedSession.cwd, + pid: opencodePid + ) + } + if let opencodePid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": opencodePid, + ]) + } + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + try setOpenCodeStatus( + client: client, workspaceId: workspaceId, - surfaceId: mappedSession.surfaceId, - cwd: parsedInput.cwd ?? mappedSession.cwd, - pid: opencodePid + value: "Running", + icon: "bolt.fill", + color: "#4C8DFF" ) + let promptSubmitSurfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + reportAgentState(client: client, workspaceId: workspaceId, surfaceId: promptSubmitSurfaceId, state: .working) + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error } - if let opencodePid { - _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ - "workspace_id": workspaceId, - "key": agentPIDKey, - "pid": opencodePid, - ]) - } - _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) - try setOpenCodeStatus( - client: client, - workspaceId: workspaceId, - value: "Running", - icon: "bolt.fill", - color: "#4C8DFF" - ) - let promptSubmitSurfaceId = try resolvePreferredSurfaceIdForClaudeHook( - preferred: mappedSession?.surfaceId, - fallback: surfaceArg, - workspaceId: workspaceId, - client: client - ) - reportAgentState(client: client, workspaceId: workspaceId, surfaceId: promptSubmitSurfaceId, state: .working) - print("{}") case "stop": do { diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index 0393ae36..2c69458e 100644 --- a/CLI/CLI+TmuxCompat.swift +++ b/CLI/CLI+TmuxCompat.swift @@ -321,8 +321,37 @@ extension ProgramaCLI { token = String(token[.. Date: Thu, 20 Aug 2026 10:10:55 -0300 Subject: [PATCH 3/5] fix: revoking a trusted device closes its live mobile-bridge connections Audit 2026-08-20 H5. revoke() only removed the device from the trust store, so an in-progress relay kept full allowlisted access until the phone disconnected on its own. Admitted connections are now registered per endpointId (under the existing stateLock, unregistered via defer on every relay exit) and revoke closes them after clearing trust. Reconnects were already rejected. --- .../MobileBridge/MobileBridgeListener.swift | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/MobileBridge/MobileBridgeListener.swift b/Sources/MobileBridge/MobileBridgeListener.swift index 6220ebc1..766dae7c 100644 --- a/Sources/MobileBridge/MobileBridgeListener.swift +++ b/Sources/MobileBridge/MobileBridgeListener.swift @@ -49,6 +49,17 @@ final class MobileBridgeListener: @unchecked Sendable { private var isStarting = false private var generation: UInt64 = 0 + /// Live connections per admitted `endpointId`, so `revoke(endpointId:)` + /// can reach an in-progress relay rather than only blocking future + /// reconnects. Keyed by `ObjectIdentifier` (not a `Set`, since + /// `Connection` isn't `Hashable`) to tolerate more than one concurrent + /// connection from the same device. Guarded by `stateLock`, the same + /// lock as every other mutable field on this type. Registered in + /// `handleIncoming` right after `admit()` succeeds; unregistered via + /// `defer` so every relay exit path (normal completion, thrown error) + /// clears its entry. + private var liveConnections: [String: [ObjectIdentifier: Connection]] = [:] + private init() {} /// Starts the endpoint if it is not already running or starting. @@ -121,9 +132,42 @@ final class MobileBridgeListener: @unchecked Sendable { return MobileBridgePairingInfo(ticket: ticket.description, token: tokenString, expiresAt: expiresAt) } - /// Revokes a previously paired device immediately. + /// Revokes a previously paired device immediately: removes it from the + /// trusted store (so a reconnect is rejected at `admit()`, unchanged) + /// and closes every connection currently registered for it, so a + /// long-lived relay session doesn't keep running on borrowed trust + /// until the phone disconnects on its own. func revoke(endpointId: String) async { await MobileBridgeTrustedDeviceStore.shared.remove(endpointId: endpointId) + + stateLock.lock() + let connections = liveConnections[endpointId] + stateLock.unlock() + + guard let connections else { return } + for connection in connections.values { + try? connection.close(errorCode: 0, reason: Data("revoked".utf8)) + } + } + + /// Registers a live, admitted connection so `revoke(endpointId:)` can + /// close it later. Must be paired with `unregisterLiveConnection` on + /// every exit path (see call site in `handleIncoming`). + private func registerLiveConnection(_ connection: Connection, endpointId: String) { + let key = ObjectIdentifier(connection) + stateLock.lock() + liveConnections[endpointId, default: [:]][key] = connection + stateLock.unlock() + } + + private func unregisterLiveConnection(_ connection: Connection, endpointId: String) { + let key = ObjectIdentifier(connection) + stateLock.lock() + liveConnections[endpointId]?[key] = nil + if liveConnections[endpointId]?.isEmpty == true { + liveConnections[endpointId] = nil + } + stateLock.unlock() } private func bindAndAccept(generation: UInt64) async { @@ -248,6 +292,14 @@ final class MobileBridgeListener: @unchecked Sendable { return } + // Registered only once admitted -- `revoke()` must never be + // able to reach a connection that hasn't passed `admit()` yet. + // Unregistered via `defer` so this fires whether `relay()` + // returns normally, this scope exits early, or an error is + // thrown while unwinding out of the enclosing `do` block. + registerLiveConnection(connection, endpointId: idString) + defer { unregisterLiveConnection(connection, endpointId: idString) } + #if DEBUG dlog("mobileBridge.connected id=\(idString)") #endif From e84a6bf1057373c15fb121d5d328aa881d397f5b Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 10:10:55 -0300 Subject: [PATCH 4/5] fix: compare drag bindings in TabItemView ==; retry declined autosaves boundedly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-08-20 H2/M3. TabItemView's == excluded draggedTabId/dropIndicator while body reads both, so .equatable() froze drag dim and drop indicators mid-drag — the file's own documented failure mode. The autosave tick recorded its fingerprint even when the save layer declined (startup restore in flight, empty snapshot), suppressing up to 60s of identical-content saves after a save that never happened; declined saves now skip the fingerprint and retry after 1s, capped at 5 consecutive attempts so a windowless app doesn't poll. --- Sources/SessionAutosaveCoordinator.swift | 20 +++++++++++++++++++- Sources/TabItemView.swift | 9 ++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/SessionAutosaveCoordinator.swift b/Sources/SessionAutosaveCoordinator.swift index 32543e34..78af491d 100644 --- a/Sources/SessionAutosaveCoordinator.swift +++ b/Sources/SessionAutosaveCoordinator.swift @@ -17,6 +17,8 @@ final class SessionAutosaveCoordinator { private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var promptSaveScheduled = false + private var consecutiveDeclinedSaveRetries = 0 + private static let maxConsecutiveDeclinedSaveRetries = 5 private var sessionAutosaveDeferredRetryPending = false private var lastSessionAutosaveFingerprint: Data? private var lastSessionAutosavePersistedAt: Date = .distantPast @@ -189,10 +191,26 @@ final class SessionAutosaveCoordinator { #if DEBUG let saveStart = ProcessInfo.processInfo.systemUptime #endif - _ = saveSnapshot(false, autosaveSnapshot) + let saved = saveSnapshot(false, autosaveSnapshot) #if DEBUG saveMs = (ProcessInfo.processInfo.systemUptime - saveStart) * 1000.0 #endif + guard saved else { + // The save layer can decline (startup restore still in flight, empty + // snapshot). Recording the fingerprint anyway would suppress up to + // 60s of identical-content saves after a save that never happened, + // and a declined prompt save reopened the escrow shadow gap it was + // built to close (audit 2026-08-20, M3). Retry, bounded: the + // restore-in-flight decline clears within a few seconds, while a + // windowless app declines indefinitely and must not become a 1s + // polling loop — the periodic timer remains the steady cadence. + if consecutiveDeclinedSaveRetries < Self.maxConsecutiveDeclinedSaveRetries { + consecutiveDeclinedSaveRetries += 1 + scheduleDeferredSessionAutosaveRetry(after: 1.0) + } + return + } + consecutiveDeclinedSaveRetries = 0 updateSessionAutosaveSaveState( includeScrollback: false, persistedAt: now, diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index c8551158..497be949 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -68,7 +68,14 @@ struct TabItemView: View, Equatable { lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && lhs.settings == rhs.settings && - lhs.showsWorktreeBadge == rhs.showsWorktreeBadge + lhs.showsWorktreeBadge == rhs.showsWorktreeBadge && + // Bindings are normally excluded (recreated per parent eval, don't + // affect rendering) — but body READS these two values (isBeingDragged + // opacity dim, showsCenteredTopDropIndicator), so excluding them froze + // drag visuals mid-drag (audit 2026-08-20, H2). Compare wrapped values; + // only drag interactions churn them, never typing. + lhs.draggedTabId == rhs.draggedTabId && + lhs.dropIndicator == rhs.dropIndicator } // Use plain references instead of @EnvironmentObject to avoid subscribing From 9bab4fbb6d8e4b600ca8da0bb8162d0a93362bfd Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 10:10:55 -0300 Subject: [PATCH 5/5] docs: resize coordinator is daemon-only, not live; document reopen-closed-panel shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 2026-08-20 M10/M11. The spec marked M-009/RZ-* DONE while nothing in the app calls session.* — now DAEMON-ONLY with the integration deferred to the detached-sessions plan. ⌘⇧T (Reopen closed panel) shipped without a row in the shortcuts doc, violating the shortcut policy. --- docs/keyboard-shortcuts.md | 1 + docs/remote-daemon-spec.md | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md index 25f99033..facd8687 100644 --- a/docs/keyboard-shortcuts.md +++ b/docs/keyboard-shortcuts.md @@ -28,6 +28,7 @@ Every shortcut is editable in `Settings → Keyboard Shortcuts` and in `~/.confi | ⌃ ⇧ Tab | Previous surface | | ⌃ 1–8 | Jump to surface 1–8 | | ⌃ 9 | Jump to last surface | +| ⌘ ⇧ T | Reopen closed panel | | ⌘ W | Close surface | ## Split panes diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 378eb855..baa746c3 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -133,7 +133,7 @@ Recompute effective size on: | M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | | M-007 | Remote proxy stream RPC in `programad-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented | | M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | -| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | +| M-009 | PTY resize coordinator (`smallest screen wins`) | DAEMON-ONLY | Daemon session RPC tracks attachments and applies min cols/rows semantics with unit tests — but the app never calls `session.*` yet (audit 2026-08-20, M10); integration is deferred to the detached-sessions plan (`docs/plans/detached-sessions.md`) | | M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | ## 7. Acceptance Test Matrix (With Status) @@ -174,13 +174,17 @@ Recompute effective size on: ### 7.4 Resize +Daemon-side semantics only: these scenarios are proven by daemon unit tests and the stdio RPC +test, but no app code calls `session.*` yet — real `programa ssh` terminals do NOT get this +behavior today (audit 2026-08-20, M10). App integration lands with the detached-sessions plan. + | ID | Scenario | Status | |---|---|---| -| RZ-001 | two attachments, smallest wins | DONE | -| RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | -| RZ-003 | detach smallest, PTY expands to next smallest | DONE | -| RZ-004 | reconnect preserves session + applies recomputed size | DONE | -| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | +| RZ-001 | two attachments, smallest wins | DAEMON-ONLY | +| RZ-002 | grow one attachment, PTY stays bounded by smallest | DAEMON-ONLY | +| RZ-003 | detach smallest, PTY expands to next smallest | DAEMON-ONLY | +| RZ-004 | reconnect preserves session + applies recomputed size | DAEMON-ONLY | +| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DAEMON-ONLY | ## 8. Removal Checklist (Port Mirroring)