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[.. 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? { 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)