diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 702f1178514a..07944f504270 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,11 +1,11 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34629 CLI/cmux.swift +34643 CLI/cmux.swift 17854 Sources/AppDelegate.swift -16499 Sources/ContentView.swift +16476 Sources/ContentView.swift 13913 Sources/TerminalController.swift -12838 Sources/Workspace.swift +12886 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12237 Sources/GhosttyTerminalView.swift 11567 Sources/Panels/BrowserPanel.swift @@ -46,8 +46,8 @@ 2126 cmuxTests/CmuxConfigTests.swift 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift 2078 Sources/SessionPersistence.swift +1960 Sources/RestorableAgentSession.swift 1952 Sources/KeyboardShortcutSettingsFileStore.swift -1945 Sources/RestorableAgentSession.swift 1900 cmuxTests/NotificationAndMenuBarTests.swift 1866 Sources/Panels/BrowserWebAuthnSupport.swift 1810 Sources/SessionIndexStore.swift @@ -71,8 +71,8 @@ 1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1295 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1295 cmuxTests/MobileHostAuthorizationTests.swift +1287 cmuxTests/RestorableAgentSessionIndexTests.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift -1277 cmuxTests/RestorableAgentSessionIndexTests.swift 1258 Sources/Feed/FeedCoordinator.swift 1209 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteSearchEngineTests.swift 1204 cmuxTests/FileExplorerStoreTests.swift @@ -98,12 +98,12 @@ 928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 926 Sources/DockPanelView.swift 920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift +914 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift -885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift 885 Sources/Panels/TerminalPanel.swift +885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift 877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift -877 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -116,14 +116,15 @@ 803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift 802 Sources/WorkspaceContentView.swift 797 Sources/ClosedItemHistory.swift +782 CLI/CMUXCLI+AgentHookDefinitions.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 773 Sources/App/MenuBarExtraController.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +761 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift -755 CLI/CMUXCLI+AgentHookDefinitions.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift @@ -142,6 +143,7 @@ 696 cmuxTests/UpdatePillReleaseVisibilityTests.swift 691 Sources/NotificationSoundSettings.swift 691 cmuxTests/TaskManagerResourcesTests.swift +690 cmuxTests/SessionIndexViewTests.swift 683 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift 683 Sources/Panels/CodexAppServerSession.swift 681 Sources/Panels/AgentSessionProcessStore.swift @@ -151,7 +153,6 @@ 668 cmuxTests/SettingsWindowPresenterTests.swift 664 Sources/CmuxTopSnapshot.swift 663 Sources/PortScanner.swift -663 cmuxTests/SessionIndexViewTests.swift 655 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift @@ -167,16 +168,16 @@ 620 cmuxTests/TerminalNotificationQueueTests.swift 613 Sources/SettingsNavigation.swift 612 cmuxUITests/FeedSidebarUITests.swift -608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +607 Sources/SessionIndexModels.swift 607 Sources/SleepyFaceView.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift 602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift 596 cmuxTests/CmuxEventBusTests.swift -594 Sources/SessionIndexModels.swift 594 cmuxTests/PortalTabDragRoutingTests.swift -590 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +591 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift 586 Sources/JSONCParser.swift @@ -197,6 +198,7 @@ 552 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift +546 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 539 CLI/CMUXCLI+Themes.swift 539 CLI/CodexTeamsApprovalBridge.swift @@ -212,7 +214,6 @@ 526 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift 525 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift 524 CLI/CMUXCLI+AutoNaming.swift -523 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift 519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 0b408eec1d13..6eca6889955e 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -377,10 +377,32 @@ extension CMUXCLI { static func hookCommandString(for def: AgentHookDef, event: AgentHookDef.HookEvent) -> String { let command = "cmux hooks \(def.name) \(event.cmuxSubcommand)" + let inline: String if def.name == "codex", codexHookCanRunFireAndForget(event.cmuxSubcommand) { - return codexFireAndForgetAgentHookShellCommand(command, for: def) + inline = codexFireAndForgetAgentHookShellCommand(command, for: def) + } else { + inline = agentHookShellCommand(command, for: def) + } + if def.name == "codex" { + return codexPersistentHookScriptCommand(inline, eventTag: event.cmuxSubcommand) } - return agentHookShellCommand(command, for: def) + return inline + } + + /// Wraps a codex persistent hook command as a `#!/bin/sh` script file in the + /// cmux-owned hooks dir and returns its path. A bare executable path runs + /// correctly under any runtime, including ones (subrouters/proxies) that exec + /// the `command` string directly and fail an inline shell snippet with + /// "No such file or directory (os error 2)". Falls back to the inline command + /// on any write failure, so the persistent install can never regress. + private static func codexPersistentHookScriptCommand(_ inlineCommand: String, eventTag: String) -> String { + guard let dir = codexHookScriptsDirectory(), + let path = writeCodexHookScript( + subcommand: "persistent-\(eventTag)", body: inlineCommand, in: dir + ) else { + return inlineCommand + } + return path } private static func codexHookCanRunFireAndForget(_ subcommand: String) -> Bool { @@ -388,21 +410,26 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { + let inline: String let noOpCommand = feedHookNoOpShellCommand(for: def, agentEvent: agentEvent) switch def.format { case .kiroAgentJSON: - return exitTwoPropagatingAgentHookShellCommand( + inline = exitTwoPropagatingAgentHookShellCommand( "cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def, noOpCommand: noOpCommand ) default: - return agentHookShellCommand( + inline = agentHookShellCommand( "cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def, noOpCommand: noOpCommand ) } + if def.name == "codex" { + return codexPersistentHookScriptCommand(inline, eventTag: "feed-\(agentEvent)") + } + return inline } private static func feedHookNoOpShellCommand(for def: AgentHookDef, agentEvent: String) -> String { diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index ec20e9f28a52..f437edaa3d66 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -1,4 +1,125 @@ +import Foundation + extension CMUXCLI { + /// The per-invocation Codex hook events the wrapper injects, paired with the + /// cmux subcommand they call and the codex hook timeout (ms). Lifecycle + /// events are short; feed events (`PreToolUse`/`PermissionRequest`) are long + /// because the user may take time to approve. This is the single source of + /// truth for `cmux-codex-wrapper`'s injection, mirrored from the historic + /// hand-rolled `cmux_codex_add_hook` calls in the wrapper. + static let codexWrapperInjectionEvents: [(agentEvent: String, cmuxSubcommand: String, timeoutMs: Int)] = [ + ("SessionStart", "session-start", 10000), + ("UserPromptSubmit", "prompt-submit", 10000), + ("Stop", "stop", 10000), + ("PreToolUse", "pre-tool-use", 120000), + ("PostToolUse", "post-tool-use", 10000), + ("PermissionRequest", "notification", 120000), + ] + + /// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must + /// splice ahead of the user's args to enable + inject cmux's fire-and-forget + /// hooks for one codex invocation. Returns the arg list: + /// --enable\0hooks\0--dangerously-bypass-hook-trust\0 + /// -c\0hooks.SessionStart=[{hooks=[{type="command",command='''''',timeout=10000}]}]\0 + /// -c\0hooks.UserPromptSubmit=...\0 ... (one `-c` pair per event) + /// where `` is `codexFireAndForgetAgentHookShellCommand(...)` so each + /// hook returns `{}` to codex instantly and backgrounds the real cmux call. + /// Requires no live socket: pure string construction from the agent def. + func emitCodexWrapperInjectArgs() throws { + guard let codexDef = Self.agentDef(named: "codex") else { + throw CLIError(message: "Codex hook integration is unavailable.") + } + // Prefer a #!/bin/sh SCRIPT FILE as the hook command over an inline shell + // snippet. Some codex-compatible runtimes (subrouters, proxies) exec the + // `command` string directly as a program instead of via a shell, so an + // inline snippet fails with "No such file or directory (os error 2)". A + // bare executable file path runs correctly whether the runtime execs it + // directly or through a shell, and normal codex (which runs it via shell) + // is unaffected. The scripts are env-driven and identical across + // invocations, so they are written once into a cmux-owned dir (~/.cmux/ + // hooks), not the user's ~/.codex. Any write failure falls back to the + // inline snippet so the working path can never regress. + let hooksDir = Self.codexHookScriptsDirectory() + var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"] + for event in Self.codexWrapperInjectionEvents { + let ff = Self.codexFireAndForgetAgentHookShellCommand( + "cmux hooks codex \(event.cmuxSubcommand)", for: codexDef + ) + let command: String + if let scriptPath = hooksDir.flatMap({ + Self.writeCodexHookScript(subcommand: event.cmuxSubcommand, body: ff, in: $0) + }), !scriptPath.contains("'''") { + command = scriptPath + } else { + command = ff + } + // TOML multi-line literal string ('''...''') preserves bytes verbatim + // and may contain single quotes, so the embedded `echo '{}'` / `sh -c + // '...'` survive with no escaping. TOML forbids only a literal triple + // single quote inside; guard against it (neither a path nor the + // command ever has one). + guard !command.contains("'''") else { + throw CLIError(message: "Codex hook command contains a triple single quote and cannot be TOML-encoded.") + } + let toml = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(command)''',timeout=\(event.timeoutMs)}]}]" + args.append("-c") + args.append(toml) + } + // NUL-TERMINATE each arg (trailing NUL after the last too) so a bash + // `while IFS= read -r -d '' arg` loop captures every element including + // the final one — a separator-only stream drops the unterminated last + // arg at EOF. + var out = Data() + for arg in args { + out.append(Data(arg.utf8)) + out.append(0) + } + FileHandle.standardOutput.write(out) + } + + /// The cmux-owned directory holding the generated codex hook scripts. + /// `~/.cmux/hooks` (NOT the user's `~/.codex`), created on demand. Returns + /// nil if it cannot be created, so the caller falls back to inline commands. + static func codexHookScriptsDirectory() -> URL? { + let home = FileManager.default.homeDirectoryForCurrentUser + let dir = home + .appendingPathComponent(".cmux", isDirectory: true) + .appendingPathComponent("hooks", isDirectory: true) + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } catch { + return nil + } + } + + /// Writes (idempotently) a `#!/bin/sh` hook script for one event into `dir` + /// and returns its absolute path, or nil on any failure. The body is the + /// same env-driven fire-and-forget snippet used inline; as a real executable + /// file it runs under any runtime, including ones that exec the hook command + /// directly rather than through a shell. Content is identical across + /// invocations, so the file is only rewritten when missing or changed. + static func writeCodexHookScript(subcommand: String, body: String, in dir: URL) -> String? { + let safeName = subcommand.replacingOccurrences( + of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression + ) + let url = dir.appendingPathComponent("cmux-codex-hook-\(safeName).sh", isDirectory: false) + let contents = "#!/bin/sh\n\(body)\n" + let fileManager = FileManager.default + if let existing = try? String(contentsOf: url, encoding: .utf8), existing == contents { + // Ensure it stays executable, then reuse. + try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + do { + try contents.data(using: .utf8)?.write(to: url, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } catch { + return nil + } + } + static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\"" diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 512c61316eba..12f08b699dd2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29681,7 +29681,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { client: client ) } - func sendAgentFeedTelemetry(workspaceId: String? = nil) { + func sendAgentFeedTelemetry(workspaceId: String? = nil, surfaceId: String? = nil) { didSendFeedTelemetry = true sendFeedTelemetry( client: client, @@ -29689,6 +29689,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { subcommand: subcommand, parsedInput: input, workspaceId: workspaceId ?? workspaceArg(), + surfaceId: surfaceId, socketPassword: socketPassword ) } @@ -29704,11 +29705,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return def.feedHookEvents.contains(event) } - func sendAgentFeedTelemetryUnlessSuppressed(workspaceId: String? = nil) { + func sendAgentFeedTelemetryUnlessSuppressed(workspaceId: String? = nil, surfaceId: String? = nil) { if shouldSuppressGenericFeedTelemetry() { didSendFeedTelemetry = true } else { - sendAgentFeedTelemetry(workspaceId: workspaceId) + sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) } } func notificationDedupeFingerprint(status: AgentHookNotificationStatus?) -> String? { @@ -29914,7 +29915,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print("{}") return } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) if !suppressVisibleMutations { if codexSessionStartWentStaleAfterAccept() { telemetry.breadcrumb("\(def.name)-hook.session-start.stale-after-turn") @@ -30177,7 +30178,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { stopStaleCodexPromptSubmit() return } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) if !sessionId.isEmpty, !suppressVisibleMutations { let acceptedRunningUpdate: Bool if def.name == "codex" { @@ -30325,7 +30326,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - sendAgentFeedTelemetry(workspaceId: workspaceId) + sendAgentFeedTelemetry(workspaceId: workspaceId, surfaceId: surfaceId) let pid = mapped?.pid ?? inferredPID let codexFailure: CodexHookFailureSummary? let codexSubagentSignals: CodexTranscriptSubagentSignals @@ -30645,7 +30646,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let workspaceId = target.workspaceId let surfaceId = target.surfaceId - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) let pid = mapped?.pid ?? inferredPID let launchCommand = agentLaunchCommandFromEnvironment( env, @@ -30786,7 +30787,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30801,7 +30802,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30948,7 +30949,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case nil: break } - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) case .sessionEnd: if def.name == "codex", !sessionId.isEmpty { @@ -30956,7 +30957,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if def.sessionEndIsTurnBoundary { if let mapped = sessionId.isEmpty ? nil : (try? store.lookup(sessionId: sessionId)) { - sendAgentFeedTelemetry(workspaceId: mapped.workspaceId) + sendAgentFeedTelemetry(workspaceId: mapped.workspaceId, surfaceId: mapped.surfaceId) _ = try? store.recordPromptStop( sessionId: sessionId, workspaceId: mapped.workspaceId, @@ -31026,6 +31027,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { subcommand: String, parsedInput: ClaudeHookParsedInput, workspaceId: String? = nil, + surfaceId: String? = nil, socketPassword: String? = nil ) { let hookEventName = Self.feedEventName(forClaudeSubcommand: subcommand) @@ -31049,6 +31051,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if let workspaceId = feedWorkspaceId(rawObject: parsedInput.object, fallback: workspaceId) { event["workspace_id"] = workspaceId } + if let surfaceId, !surfaceId.isEmpty { + event["surface_id"] = surfaceId + } + if let transcriptPath = parsedInput.transcriptPath, !transcriptPath.isEmpty { + event["transcript_path"] = transcriptPath + } if let cwd = parsedInput.cwd { event["cwd"] = cwd } let toolName = parsedInput.object?["tool_name"] as? String if let toolName, !toolName.isEmpty { @@ -33813,6 +33821,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let actionArgs = Array(rest.dropFirst()) switch action { + case "inject-args" where def.name == "codex": + // Hidden: emit the NUL-separated codex arg list the wrapper + // (Resources/bin/cmux-codex-wrapper) splices to inject cmux's + // fire-and-forget hooks for one invocation. No socket required. + try emitCodexWrapperInjectArgs() + return true case "install": try installHooksForAgent(def, arguments: actionArgs) return true diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatQuestion.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatQuestion.swift index 5d247065bda7..d2d048904bf2 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatQuestion.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatQuestion.swift @@ -40,21 +40,36 @@ public struct ChatQuestion: Sendable, Equatable, Codable { /// The label of the chosen option once answered, `nil` while pending. public let selectedOptionLabel: String? + /// The agent's own id for this question, when it keys answers by id rather + /// than by prompt (Codex `request_user_input` does; Claude keys by prompt, + /// so this is `nil` there). Lets a multi-question call resolve each card to + /// its own answer. + public let questionID: String? + /// Creates a question. /// /// - Parameters: /// - prompt: The question text. /// - options: Selectable answers in display order. /// - selectedOptionLabel: Chosen option label once answered. - public init(prompt: String, options: [Option], selectedOptionLabel: String? = nil) { + /// - questionID: The agent's id for this question, when answers are keyed + /// by id (Codex). `nil` for prompt-keyed agents (Claude). + public init( + prompt: String, + options: [Option], + selectedOptionLabel: String? = nil, + questionID: String? = nil + ) { self.prompt = prompt self.options = options self.selectedOptionLabel = selectedOptionLabel + self.questionID = questionID } private enum CodingKeys: String, CodingKey { case prompt case options case selectedOptionLabel = "selected_option_label" + case questionID = "question_id" } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift index 3ce10a9e2994..789c3b188c6b 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/ChatSessionDescriptor.swift @@ -38,6 +38,13 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable /// Timestamp of the most recent transcript or hook activity. public let lastActivityAt: Date? + /// Monotonic per-session revision, bumped by the host on every change to + /// this session. The client reconciles best-effort pushes against + /// authoritative pulls by this number: apply a push only when its version + /// is strictly greater than the last applied, and replace wholesale from a + /// snapshot pull. A missed or duplicated push self-heals on the next pull. + public var version: Int = 0 + /// Creates a session descriptor. /// /// - Parameters: @@ -86,7 +93,8 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable terminalID: String? = nil, workingDirectory: String? = nil, state: ChatAgentState = .idle, - lastActivityAt: Date? = nil + lastActivityAt: Date? = nil, + version: Int = 0 ) { self.id = id self.agentKind = agentKind @@ -97,6 +105,7 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable self.workingDirectory = workingDirectory self.state = state self.lastActivityAt = lastActivityAt + self.version = version } /// A copy with a new live state, leaving identity and bindings intact. @@ -115,7 +124,8 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable terminalID: terminalID, workingDirectory: workingDirectory, state: newState, - lastActivityAt: lastActivityAt + lastActivityAt: lastActivityAt, + version: version ) } @@ -129,6 +139,7 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable case workingDirectory = "cwd" case state case lastActivityAt = "last_activity_at" + case version } // Custom Codable so `kind` decodes with a `.agent` default when absent @@ -145,6 +156,7 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable workingDirectory = try container.decodeIfPresent(String.self, forKey: .workingDirectory) state = try container.decode(ChatAgentState.self, forKey: .state) lastActivityAt = try container.decodeIfPresent(Date.self, forKey: .lastActivityAt) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 0 } public func encode(to encoder: any Encoder) throws { @@ -158,5 +170,6 @@ public struct ChatSessionDescriptor: Identifiable, Sendable, Equatable, Codable try container.encodeIfPresent(workingDirectory, forKey: .workingDirectory) try container.encode(state, forKey: .state) try container.encodeIfPresent(lastActivityAt, forKey: .lastActivityAt) + try container.encode(version, forKey: .version) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift index 6d52db561fc8..98ae570ad381 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift @@ -205,6 +205,32 @@ public struct CodexTranscriptParser: Sendable { let callID = payload["call_id"]?.string let arguments = payload["arguments"]?.string let parsedArguments = arguments.flatMap { TranscriptJSONValue(jsonLine: $0) } + // Codex's interactive picker is a `request_user_input` function call whose + // arguments carry `questions[]` in the same shape as Claude's + // AskUserQuestion. Render each as a tappable `.question` so the GUI shows + // a real picker (wired to mobile.chat.answer) instead of plain text. + if name == "request_user_input" { + let questions = Self.codexQuestions(from: parsedArguments) + if !questions.isEmpty { + for (index, question) in questions.enumerated() { + let baseID = callID ?? "line-\(seq)" + assembler.append( + ChatMessage( + id: index == 0 ? baseID : "\(baseID)-q\(index)", + seq: seq, + role: .agent, + timestamp: timestamp, + kind: .question(question) + ), + // Pair with the request_user_input function_call_output by + // call id so the answer marks the question resolved (the + // GUI then shows the selection and stops being tappable). + pendingKey: index == 0 ? callID : nil + ) + } + return + } + } let kind: ChatMessageKind if Self.shellToolNames.contains(name), let command = shellCommand(arguments: parsedArguments, payload: payload) { @@ -228,6 +254,27 @@ public struct CodexTranscriptParser: Sendable { ) } + /// Maps a `request_user_input` arguments object into tappable questions. + /// Mirrors the Claude parser's question shape: `questions[].question` with + /// `options[].label` and an optional `options[].description` detail. + private static func codexQuestions(from arguments: TranscriptJSONValue?) -> [ChatQuestion] { + let questions = arguments?["questions"]?.array ?? [] + return questions.compactMap { question -> ChatQuestion? in + guard let prompt = question["question"]?.string else { return nil } + let options = (question["options"]?.array ?? []).compactMap { option in + option["label"]?.string.map { + ChatQuestion.Option(label: $0, detail: option["description"]?.string) + } + } + guard !options.isEmpty else { return nil } + return ChatQuestion( + prompt: prompt, + options: options, + questionID: question["id"]?.string + ) + } + } + private func appendCustomToolCall( _ payload: TranscriptJSONValue, seq: Int, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/TranscriptToolCompletion.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/TranscriptToolCompletion.swift index 25c4d2b92981..775274840918 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/TranscriptToolCompletion.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/TranscriptToolCompletion.swift @@ -63,11 +63,20 @@ struct TranscriptToolCompletion: Sendable { ) return message.replacingKind(.toolUse(completed)) case .question(let question): - guard let answer = answer(forPrompt: question.prompt) else { return nil } + // Codex keys answers by question id, so a multi-question call + // resolves each card to its own answer; Claude keys by prompt. + let answer: String? + if let questionID = question.questionID { + answer = self.answer(forCodexQuestionID: questionID) + } else { + answer = self.answer(forPrompt: question.prompt) + } + guard let answer else { return nil } let answered = ChatQuestion( prompt: question.prompt, options: question.options, - selectedOptionLabel: answer + selectedOptionLabel: answer, + questionID: question.questionID ) return message.replacingKind(.question(answered)) default: @@ -75,19 +84,59 @@ struct TranscriptToolCompletion: Sendable { } } - /// Extracts the chosen answer for a question prompt from the - /// `Your questions have been answered: "Q"="A"...` result text. + /// Extracts the chosen answer for a question prompt. + /// + /// Handles two formats: + /// - Claude: `Your questions have been answered: "Q"="A"...`. + /// - Codex `request_user_input`: a JSON output + /// `{"answers":{"":{"answers":["