From 6d67c0a754c974bb8bfcf9a78cf5596aec9cdbf9 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:00:09 -0700 Subject: [PATCH 01/38] agent-session SoT: add session version + authoritative pull snapshot (Slice A) Foundation for the reliable agent-session tracking redesign (see docs/agent-session-tracking-spec.md). Makes the host the single source of truth and gives the client an authoritative pull path so a missed or out-of-order best-effort push self-heals. - ChatSessionDescriptor + AgentChatSessionRecord gain a monotonic `version`, stamped by AgentChatSessionRegistry on every write (one chokepoint, counter not hash, so strict monotonicity holds even when a change reverts a field). - New `mobile.chat.session` RPC: authoritative single-session snapshot pull for reconnect / foreground / version-gap / manual-refresh. - ChatSessionListReducer version-gates descriptor upserts: a lower-version push never clobbers newer state from a later push or a snapshot pull. Equal version passes through (counter guarantees equal == identical content; keeps unversioned payloads upserting as before). +1 unit test. - iOS MobileChatEventSource.session(sessionID:) pull primitive + response type. Verified: CmuxAgentChat builds + 128 tests pass; CmuxMobileShell builds; full macOS app builds (tag agentsot). No heuristics removed yet; no behavior removed. iOS pull-trigger wiring and the process-exit backstop are next. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Model/ChatSessionDescriptor.swift | 17 +- .../Store/ChatSessionListReducer.swift | 13 + .../ChatSessionListReducerTests.swift | 24 ++ .../MobileChatEventSource.swift | 23 ++ .../MobileChatSessionResponse.swift | 19 + Resources/Localizable.xcstrings | 17 + .../AgentChat/AgentChatSessionRecord.swift | 8 +- .../AgentChat/AgentChatSessionRegistry.swift | 24 +- Sources/TerminalController+MobileChat.swift | 30 ++ docs/agent-session-tracking-spec.md | 352 ++++++++++++++++++ 10 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift create mode 100644 docs/agent-session-tracking-spec.md 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/Store/ChatSessionListReducer.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift index 5ba1b63c8ef8..82605a149368 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift @@ -38,6 +38,19 @@ public struct ChatSessionListReducer: Sendable { } var updated = sessions if let index = updated.firstIndex(where: { $0.id == descriptor.id }) { + // Version-gated upsert: best-effort pushes can arrive out of + // order, be duplicated, or race an authoritative pull. The host + // stamps a strictly increasing `version` on every change, so a + // descriptor whose version is LOWER than the one already + // applied is stale (or out of order) and must not clobber newer + // state the client got from a later push or a snapshot pull. + // Equal version is allowed through (a no-op in practice: the + // monotonic counter guarantees equal version == identical + // content), which also keeps unversioned (version 0) payloads + // upserting as before. + guard descriptor.version >= updated[index].version else { + return sessions + } updated[index] = descriptor } else { updated.append(descriptor) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift index 373ac5496acc..2902f088d55d 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift @@ -100,4 +100,28 @@ struct ChatSessionListReducerTests { ) #expect(reducer.applying(frame, to: seed).count == 1) } + + @Test("a lower-version descriptorChanged is dropped; a higher one applies") + func versionGatedUpsert() { + let reducer = ChatSessionListReducer(workspaceID: "ws-1") + func desc(_ state: ChatAgentState, _ version: Int) -> ChatSessionDescriptor { + ChatSessionDescriptor( + id: "s1", agentKind: .claude, workspaceID: "ws-1", + terminalID: "s1", state: state, version: version + ) + } + // Seed at version 5 (working). + let seed = [desc(Self.working, 5)] + // A stale push (version 3, idle) arrives out of order and is dropped: + // the newer working state the client already holds must survive. + let stale = ChatSessionEventFrame(sessionID: "s1", event: .descriptorChanged(desc(.idle, 3))) + let afterStale = reducer.applying(stale, to: seed) + #expect(afterStale.first?.state == Self.working) + #expect(afterStale.first?.version == 5) + // A newer push (version 6, ended) applies. + let newer = ChatSessionEventFrame(sessionID: "s1", event: .descriptorChanged(desc(.ended, 6))) + let afterNewer = reducer.applying(newer, to: afterStale) + #expect(afterNewer.first?.state == .ended) + #expect(afterNewer.first?.version == 6) + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index 5d67bc71ae49..6aee369aeae0 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -38,6 +38,29 @@ public actor MobileChatEventSource: ChatEventSource { return try coding.decode(MobileChatSessionsResponse.self, from: result).sessions } + /// Pulls the authoritative snapshot of one session by id. + /// + /// The client's reconcile path: on (re)connect, foreground, a detected + /// version gap, or manual refresh, the host fetches the current descriptor + /// and folds it through the same version-gated upsert as a push, so a pull + /// that races a newer push converges. Pull is authoritative; push is a + /// best-effort hint, so a missed or out-of-order push self-heals here. + /// + /// Not part of ``CmuxAgentChat/ChatEventSource`` (which is scoped to one + /// conversation). Throws when the host no longer knows the session (treat + /// as gone) or the request fails. + /// + /// - Parameter sessionID: The session to snapshot. + /// - Returns: The session's current descriptor, with its `version`. + public func session(sessionID: String) async throws -> ChatSessionDescriptor { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.chat.session", + params: ["session_id": sessionID] + ) + let result = try await client.sendRequest(request) + return try coding.decode(MobileChatSessionResponse.self, from: result).session + } + /// Opens the live stream of session-list events for every session the /// Mac knows about (not scoped to one conversation). /// diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift new file mode 100644 index 000000000000..cd18285e2ba7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift @@ -0,0 +1,19 @@ +public import CmuxAgentChat + +/// Result payload of `mobile.chat.session` (single-session snapshot pull). +public struct MobileChatSessionResponse: Sendable, Equatable, Codable { + /// The authoritative current descriptor for the requested session, + /// carrying the host's monotonic `version` for reconciliation. + public let session: ChatSessionDescriptor + + /// Creates a response. + /// + /// - Parameter session: The session descriptor. + public init(session: ChatSessionDescriptor) { + self.session = session + } + + private enum CodingKeys: String, CodingKey { + case session + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 99829b91802a..29e6c669accd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -210144,6 +210144,23 @@ } } }, + "mobile.chat.error.sessionNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That agent session is no longer available." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのエージェントセッションは利用できなくなりました。" + } + } + } + }, "mobile.chat.error.transcriptNotReadable": { "extractionState": "manual", "localizations": { diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift b/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift index 68708120c2b0..9e3768bae042 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRecord.swift @@ -35,6 +35,11 @@ struct AgentChatSessionRecord: Sendable { /// The agent process id, for liveness sweeps. var pid: Int? + /// Monotonic revision stamped by the registry on every change, so clients + /// can reconcile best-effort pushes against authoritative pulls. Owned by + /// the registry; mutators do not set it directly. + var version: Int = 0 + /// Adopts terminal/transcript bindings from a hook-store entry. The /// store is rewritten by every hook event, so its non-nil fields are /// fresher than the record's (panel UUIDs change across app @@ -70,7 +75,8 @@ struct AgentChatSessionRecord: Sendable { terminalID: surfaceID, workingDirectory: workingDirectory, state: state, - lastActivityAt: lastActivityAt + lastActivityAt: lastActivityAt, + version: version ) } } diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 55f18f8d6f4d..6393f970e011 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -19,6 +19,22 @@ final class AgentChatSessionRegistry { /// main-actor disk reads during tool storms. private var hookStoreConsultedAt: [String: Date] = [:] + /// Per-session monotonic revision counter. Every stored record carries the + /// current value so clients reconcile best-effort pushes against + /// authoritative pulls: apply a push only when its version exceeds the last + /// applied, replace wholesale on a snapshot pull. A counter (not a hash) + /// guarantees strict monotonicity even when a change reverts a field. + private var versionBySessionID: [String: Int] = [:] + + /// Stamps the next monotonic version onto a record before it is stored. + /// All write paths route through this so no externally visible change ever + /// ships with a stale or unchanged version. + private func stampVersion(_ record: inout AgentChatSessionRecord) { + let next = (versionBySessionID[record.sessionID] ?? 0) + 1 + versionBySessionID[record.sessionID] = next + record.version = next + } + /// Creates a registry. /// /// - Parameter hookStore: Reader for the per-agent hook session stores. @@ -119,6 +135,7 @@ final class AgentChatSessionRegistry { guard let previous = records[sessionID] else { return } var record = previous mutate(&record) + stampVersion(&record) records[sessionID] = record updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) @@ -150,7 +167,7 @@ final class AgentChatSessionRegistry { for entry in hookStore.entries(agentSource: source) { guard records[entry.sessionID] == nil else { continue } let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false - let record = AgentChatSessionRecord( + var record = AgentChatSessionRecord( sessionID: entry.sessionID, agentKind: kind, workspaceID: entry.workspaceID, @@ -162,6 +179,7 @@ final class AgentChatSessionRegistry { title: nil, pid: entry.pid ) + stampVersion(&record) records[entry.sessionID] = record updateLiveSessionIndex(previous: nil, current: record) } @@ -196,7 +214,7 @@ final class AgentChatSessionRegistry { if let bound = liveSession(surfaceID: surfaceID) { return bound } - let record = AgentChatSessionRecord( + var record = AgentChatSessionRecord( sessionID: sessionID, agentKind: agentKind, workspaceID: workspaceID, @@ -208,6 +226,7 @@ final class AgentChatSessionRegistry { title: nil, pid: nil ) + stampVersion(&record) records[sessionID] = record updateLiveSessionIndex(previous: nil, current: record) onRecordChanged?(record, nil) @@ -274,6 +293,7 @@ final class AgentChatSessionRegistry { let previous = records[sessionID] record.state = Self.nextState(previous: record.state, event: event) + stampVersion(&record) records[sessionID] = record updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 7f1857adf58f..59aaf9af325a 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -33,6 +33,8 @@ extension TerminalController { switch method { case "mobile.chat.sessions": return v2MobileChatSessions(params: params) + case "mobile.chat.session": + return v2MobileChatSession(params: params) case "mobile.chat.history": return await v2MobileChatHistory(params: params) case "mobile.chat.send": @@ -79,6 +81,34 @@ extension TerminalController { return .ok(["sessions": encoded]) } + /// `mobile.chat.session`: authoritative snapshot of one session by id. + /// + /// The client's pull path: on (re)connect, foreground, a detected version + /// gap, or manual refresh, the phone fetches the current descriptor (with + /// its monotonic `version`) and reconciles wholesale, so a missed or + /// out-of-order best-effort push self-heals. `not_found` means the session + /// is unknown to the host (e.g. cleared); the client drops it. + func v2MobileChatSession(params: [String: Any]) -> V2CallResult { + guard let sessionID = v2String(params, "session_id"), !sessionID.isEmpty else { + return .err(code: "invalid_params", message: "session_id required", data: nil) + } + guard let service = agentChatTranscriptService else { + return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) + } + guard let record = service.sessionRecord(sessionID: sessionID), + let encoded = service.wirePayload(record.descriptor) else { + return .err( + code: "not_found", + message: String( + localized: "mobile.chat.error.sessionNotFound", + defaultValue: "That agent session is no longer available." + ), + data: ["session_id": sessionID] + ) + } + return .ok(["session": encoded]) + } + /// Scans a workspace's terminals for a running coding agent that has no /// chat session yet (title- or launch-metadata-detected, no hook) and /// adopts it. Adoption is a no-op once the surface has a session, so this diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md new file mode 100644 index 000000000000..20010b05f1f6 --- /dev/null +++ b/docs/agent-session-tracking-spec.md @@ -0,0 +1,352 @@ +# Agent Session Tracking — Single Source of Truth (Technical Spec) + +Status: DRAFT, living doc. Owner: Aziz. Last updated: 2026-06-22. + +Tracks the redesign of how cmux tracks whether a terminal surface has a coding +agent (Claude Code / Codex) session, for the iOS GUI ("coding agent") mode. + +## Goal + +The iOS GUI must reliably know, for any cmux surface: is there an agent session, +what is its live state, and where is its transcript. "Reliably" is the whole +point. No heuristic that can show the wrong conversation or none. + +## Principles + +1. **Single source of truth.** One authoritative `AgentSession` record per + surface, on the Mac. Everything else derives from it. +2. **Deterministic binding, no heuristics.** Surface to session binding is + established by construction at terminal/agent start, keyed by a cmux-minted + token. Never from terminal-title string matching. Never from newest-file-by- + mtime scans. Never from any signal that can mis-attribute. +3. **No unreliable fallback.** If the reliable path has a gap, close the gap in + the launch/env binding. Do not paper over it with a heuristic fallback. +4. **Push is best-effort; pull is authoritative.** Mac to iOS push events are + hints. iOS can always re-fetch the authoritative current snapshot and + reconcile. A missed or duplicated push self-heals on the next pull. +5. **Don't rely on perfect hook delivery.** Neither agent-to-Mac hooks nor + Mac-to-iOS pushes are assumed delivered. Reliable backstops only (owned + process exit; the agent's own transcript file), never titles/mtime. +6. **`ended` is retained, not deleted.** GUI stays shown after the agent ends. + Ended is a flag that disables the input/bottom bar. It must not gate + presence and must not delete the session. +7. **Bind on a durable surface identity.** The binding key is the surface id, + which MUST be invariant for a terminal's whole life (persisted, rehydrated + verbatim on restore, never re-minted). Workspace id is volatile and is never + the key. See "Identity and the binding key". +8. **No file I/O or JSON/JSONL parsing on the main thread.** The main actor + receives only already-parsed `Sendable` value snapshots. See "Threading + discipline". + +## Phase 1 audit findings (2026-06-22) + +The reliable binding mechanism ALREADY EXISTS; the heuristic layer was redundant. + +Env token, unconditional. Every local terminal surface injects +`CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` / `CMUX_PANEL_ID` / `CMUX_TAB_ID` as +PROTECTED env keys at spawn (`TerminalSurface+StartupEnvironment.swift:45-62`, +`applyManagedCmuxContextEnvironment`). Remote shells inject the same via +placeholder substitution (`RemoteInteractiveShellBootstrapBuilder.swift:191-196`). +So a hand-typed `claude`/`codex` in any cmux terminal already inherits the +surface token. + +Hook surface resolution is multi-signal and cmux-owned +(`CLI/cmux.swift:22901-22935`, `resolveCallerTerminalBindingByTTY:24336`, +`resolveAgentProcessTerminalBinding:24343`, `resolveTerminalBinding:24419`). +A fired hook resolves its surface by, in order: +1. explicit `--workspace` / `--surface` flags, +2. inherited env `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID`, +3. tty -> surface: the agent's controlling tty matched against the app's + `debug.terminals` table (`tty` -> `surface_id`), +4. process-tree: the agent pid found under a surface's `top_level_pids` / + process tree via `system.top`. +Signals 3 and 4 need no env at all; they map the agent's real tty/pid to the +surface cmux owns. This is the deterministic binding the redesign blesses. + +Entrypoint coverage (each ends with a hook whose surface resolves by the above): +- `cmux claude-teams` / hand-typed `claude`: COVERED. Hooks injected via + `--settings` by `cmux-claude-wrapper`; env inherited; tty/pid backstop. +- `cmux new-surface --type agent-session` (UI/CLI): COVERED. App injects + `CMUX_SURFACE_ID` into the launch env (`AppDelegate.swift:6256`). +- Session restore, fork-session, remote/ssh: COVERED (env + tty/pid). +- `codex` (bare or `codex-teams`): PARTIAL. No `--settings`-style auto-inject + wrapper; relies on `cmux hooks setup codex` having installed `~/.codex` hooks. + Once installed, the fired hook resolves the surface identically. GAP = ensure + setup ran; not a per-launch binding gap. +- Degraded mode: `cmux-claude-wrapper` execs claude WITHOUT hook injection when + the socket ping fails (`cmux-claude-wrapper:414`). Acceptable: the agent still + runs; we simply do not track it until the socket returns and the next + hook/pull reconciles. Never a correctness hole, only staleness. + +Conclusion: do NOT build a new binding mechanism. Make the authority consume the +already-resolved hook events (+ process-exit), add versioning/pull, and DELETE +the title/mtime layer. Codex parity = guarantee hook setup; that is the only +genuine launch-path gap. + +## The binding: surface resolution at hook time (already reliable) + +cmux owns every cmux terminal's environment. When it spawns a surface's shell, +it injects: + +- `CMUX_SURFACE_ID` — stable, cmux-owned, the binding key. +- `CMUX_WORKSPACE_ID`. +- the agent hook config, so `cmux hooks ` fires for any agent + started in that shell and carries the surface token. + +Because the token lives at the shell/env layer (which cmux always controls for +its own terminals), even a hand-typed `claude`/`codex` inherits it and its hooks +bind to the correct surface. This is what lets us delete the title/mtime +fallback: there is no gap for a cmux-owned surface. An agent in a terminal cmux +does NOT own (ssh/remote/external) is not a cmux surface and has nothing to show +in GUI mode, so no fallback is needed. + +Open task: enumerate every way an agent can start in a cmux surface (new-agent +action, CLI, fork-agent, restored session, hand-typed in shell) and confirm each +inherits the surface env token + hook config. This audit is the prerequisite for +deleting the heuristics. See "First PR" below. + +## Identity and the binding key (durability of cmux IDs) + +Reality today (evidence: `Sources/Workspace.swift`, `Sources/TabManager.swift`): + +- **Surface id** (`CMUX_SURFACE_ID` / `CMUX_PANEL_ID`; == panel id == ghostty + surface id): persisted in the session snapshot and, since a recent fix + (commit `44dc053e`, 2026-06-12), REUSED verbatim on restore so agent bindings + survive relaunch (`Workspace.swift:1340`). Regenerated ONLY on collision: when + a live surface already holds that id (restore-into-a-running-instance, + duplicate-workspace). On a normal quit-then-reopen there is no collision, so + it is stable. +- **Workspace id** (`CMUX_WORKSPACE_ID` / `CMUX_TAB_ID`): ALWAYS regenerated on + restore. Restore builds a brand-new Workspace via the normal initializer + (`UUID()`); the saved id is kept only to remap closed-panel history + (`TabManager.swift:5963`), never rehydrated as the live id. +- **Pane id**: internal, layout-only, not persisted, not an env var. +- **Window**: OS-managed (`NSWindow`), not a cmux model identity. + +Why they change: session restore reconstructs the object graph through standard +initializers, each minting a fresh `UUID()`. Only the surface id was +special-cased to be preserved, and only recently, precisely because regenerating +it was breaking agent-session bindings (the reason +`AgentChatSessionRegistry.refreshBindingsFromHookStore` exists). It is not a +deliberate "ids should rotate"; it is "restore mints fresh objects, and only the +id something depended on was preserved." + +Consequence for this design: + +- The binding key is the SURFACE id, and the design REQUIRES it to be a durable + identity: minted once at surface creation, persisted, rehydrated verbatim on + restore, never re-minted. The recent reuse fix is the right direction; this + design depends on it AND requires closing the collision-regeneration hole + (handle restore-into-live by not duplicating the surface rather than by + re-id'ing it), so `CMUX_SURFACE_ID` is invariant for a terminal's whole life. +- Do NOT key the binding on workspace id. It is volatile across relaunch. + `workspaceID` is a mutable ATTRIBUTE of the AgentSession (for filtering / + display), refreshed from hook events, never the identity. +- Blocking prerequisite (alongside the entrypoint audit): audit and guarantee + surface-id durability (persist + verbatim rehydrate + no collision re-id). + Until that invariant holds, "bind by surface id" is not yet reliable. + +## The authority: one AgentSession per surface + +Replace the multi-map registry (records + liveSessionIDBySurfaceID + claim sets) +with a single authoritative record, keyed by surface: + +``` +AgentSession { + surfaceID // binding key, stable, cmux-owned + workspaceID + agentKind // claude | codex + sessionID // from the first hook event for this surface token + transcriptPath // recorded from the hook; never guessed + state // launching | idle | working | needsInput | ended + pid // process cmux spawned (or adopted via the env token) + version // monotonically increasing; bumped on every change + lastActivityAt +} +``` + +- GUI mode = "this surface has an AgentSession." +- Input/bottom bar enabled/disabled is driven by `state` (disabled on `ended`), + not by presence. +- `version` is the reconciliation key for the pull/push model below. + +## State source: hook events tied to the token + +State transitions come from exactly one channel: agent hook events over the +socket, each carrying `CMUX_SURFACE_ID`, so each event maps unambiguously to one +AgentSession. No reconciliation across same-cwd sessions, no "which one is this." + +State machine (from the existing `nextState`): + +- `sessionStart` -> idle +- `userPromptSubmit | preToolUse | postToolUse | todoWrite` -> working +- `permissionRequest | askUserQuestion | exitPlanMode | notification` -> needsInput +- `stop` -> idle +- `subagentStop` -> unchanged (a Task subagent finishing says nothing about the parent) +- `sessionEnd` -> ended + +Every applied event bumps `version` and `lastActivityAt`. + +## Reliability model: authoritative state + versioned pull, push as hint + +This is the core of "super reliable" and "voluntarily readable." + +### Mac side (authoritative) + +- The Mac holds the authoritative AgentSession per surface. This is the single + source of truth. +- `version` increments on every state or binding change. +- Backstops that do NOT depend on hook delivery: + - **Owned-process exit.** cmux spawned (or adopted) the agent pid, so a child + `terminationHandler` / `waitpid` gives `ended` deterministically even if the + `sessionEnd` hook never arrives. This replaces the `kill(pid,0)` polling + sweep. + - **Transcript corroboration.** The agent's own transcript JSONL is a reliable + record (unlike titles). A completed assistant turn observed in the tail can + clear a stuck `working`. Used only to correct, never to invent presence. + +### Mac -> iOS (push is best-effort) + +- Push events (`chat.message`, state/descriptor changes) are delivery hints. + Each carries the new `version`. Never assume a push arrived. + +### iOS -> Mac (pull is authoritative) + +iOS can fetch the authoritative current snapshot at any time: + +- `mobile.chat.sessions` — full descriptors for a workspace (state + version + + transcript binding). The list-level snapshot. +- per-session/surface snapshot fetch — current state + version for one session. +- `mobile.chat.history` — seq-based transcript paging (already pull-capable). + +iOS pulls on: (re)connect, app foreground, (re)subscribe, detected version gap +(received version is not lastSeen+1, or any push it cannot reconcile), and +explicit user refresh. + +### Reconciliation (iOS) + +- Apply a push only if its `version` is greater than the last applied version + for that session. Monotonic, so duplicates and reorderings are no-ops. +- On pull, replace local state with the snapshot's state+version. +- Transcript: paged by seq; a `.reset` (transcript rotation, inode change) drops + the cursor and re-pages. + +Result: any missed push is corrected by the next pull; any stale local state is +overwritten by an authoritative snapshot. Delivery failures degrade to "slightly +stale until next pull," never to "wrong or missing forever." + +## Threading discipline + +Hard rule: no file I/O and no JSON/JSONL parsing on the main thread/actor. The +main actor receives only already-parsed `Sendable` value snapshots. + +- Reference pattern (already correct): `AgentChatTranscriptTailer` is a dedicated + `actor` (not `@MainActor`). It memory-maps the transcript, scans newlines and + parses JSONL off the main executor, and pushes `Sendable` `Batch` values out. + Keep this shape for all transcript work. +- Current violation to remove: `AgentChatSessionRegistry` is `@MainActor` and + reads + JSON-parses the hook store (`Data(contentsOf:)` + `JSONSerialization`) + inline in `seedFromHookStores` / `noteHookEvent`. That is the main-actor + disk+parse cost the 30s throttle was bounding. The redesign deletes the + hook-store-as-truth, so this parse leaves the main actor entirely. +- Going forward: hook-event decode (`WorkstreamEvent`), any session-state + persistence read/write, and all transcript work run off-main. The authority + applies only small pre-parsed value mutations on the main actor (or is itself a + non-main actor that publishes `Sendable` snapshots to the UI). No whole-file + reads, no `JSONSerialization`, no `Codable` decode on the main thread. + +## Persistence across app relaunch + +Bind by token, not by regenerated panel UUIDs (today's relaunch regenerates +panel UUIDs and re-consults the JSON store to re-bind): + +- Persist the per-surface AgentSession keyed by the stable surface token. +- On restore, re-attach by token (the live agent process still carries + `CMUX_SURFACE_ID`; cmux can re-adopt the pid it owns), or mark `ended` if the + process is gone. Deterministic either way. + +## What gets deleted + +- `AgentChatTranscriptService+TitleDetection.swift` in full. +- `claudeTitleDetectionKey` / `specificClaudeTitleKey` title matching. +- `newestClaudeTranscript` + the `excludingSessionIDs` / forced-retry / claim + machinery, the `$HOME` junk-drawer guard, the `/tmp` vs `/private/tmp` + cwd-encoding gymnastics. +- `~/.cmuxterm/-hook-sessions.json` as a read-side source of truth (it may + remain a CLI-side scratch artifact, but the app stops reading it on the hot + path). +- the 30s store-read throttle (no store reads on the hot path; state was always + real-time from hooks, the throttle was only on disk re-consult for missing IDs). +- the `kill(pid,0)` liveness sweep (replaced by owned-process termination). + +## What stays + +- The hook event -> state machine (`nextState`). +- The JSONL transcript tailer (`AgentChatTranscriptTailer`) for streaming + history. It reads the recorded `transcriptPath` only. +- The iOS RPC surface (`mobile.chat.sessions / history / subscribe / send / + interrupt / answer`), extended with explicit `version` on descriptors and a + per-session snapshot fetch. + +## RPC surface (target) + +- `mobile.chat.sessions(workspaceID?) -> [SessionDescriptor]` where each + descriptor includes `state` and `version` and the transcript binding. +- `mobile.chat.session(surfaceID | sessionID) -> SessionDescriptor` (snapshot of + one). +- `mobile.chat.history(sessionID, beforeSeq?, limit) -> page` (pull transcript). +- `mobile.events.subscribe / unsubscribe` (best-effort push, carries `version`). +- `mobile.chat.send / interrupt / answer` (input; gated off when `state == ended`). + +## Implementation plan (slices) + +Phase 1 audit is DONE (see "Phase 1 audit findings"). The reliable binding +already exists, so the remaining work is additive-then-subtractive, in +reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff +(runtime PRs do not merge before dogfood approval). + +- **Slice A — Versioning + pull snapshot (additive, no deletes).** Add a + monotonic `version` to each session descriptor, bumped on every registry + change. `mobile.chat.sessions` carries `version`; add `mobile.chat.session` + (single-session snapshot pull). iOS reconciles by version and pulls on + (re)connect / foreground / version gap / manual refresh. Pure reliability win, + no behavior removed. Lowest risk, ships first. +- **Slice B — Owned-process-exit backstop.** Observe the agent pid cmux owns via + a termination handler; deterministic `ended` without the `sessionEnd` hook. + Keep `ended` retained (disables input bar, stays visible). Remove the + `kill(pid,0)` polling sweep once the handler covers it. +- **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor` + (interim, before it is deleted) and assert no `Data(contentsOf:)` / + `JSONSerialization` / `Codable` decode on the main actor in this subsystem. +- **Slice D — Delete the heuristics.** Remove + `AgentChatTranscriptService+TitleDetection.swift`, `newestClaudeTranscript` + + claim/forced-retry machinery, the hook-store as read-side source of truth, and + the 30s throttle. Gated on Slices A-B proving the resolved-hook + pull path is + sufficient. +- **Slice E — Surface-id invariance (only if needed).** Close the collision + regeneration (restore-into-live / duplicate-workspace) so `CMUX_SURFACE_ID` is + invariant; extend verbatim-rehydrate to non-terminal panels if the GUI uses an + `agent-session` panel surface. Touch points: + `Workspace.swift:1347-1348,1421,1435,1446,1470`, `*Panel.swift` inits. + Deferred unless a concrete relaunch-rebinding bug demands it; the + terminal-agent model rehydrates verbatim on normal relaunch already. +- **Slice F — Codex parity.** Guarantee `cmux hooks setup codex` is in effect so + codex hooks fire and resolve the surface like claude. + +## Verification + +- Build/compile via tagged `-derivedDataPath /tmp/cmux-` (never untagged). +- Tests run on AWS M4 Pro or GitHub Actions, never locally (test host launches a + `cmux DEV` app). +- Each runtime slice: tagged reload + dogfood handoff with prior-bad vs expected + behavior, merge only on explicit dogfood approval. + +## Open questions + +- Exact shape of the persisted per-surface AgentSession (where it lives, how it + survives relaunch without the old UUID-regeneration problem). +- Whether the transcript corroboration backstop is needed at all once + owned-process exit + hook events are solid, or whether it stays only for the + Claude weekly-limit "no Stop hook" case. +- Codex parity for every mechanism (token inheritance, hook events, transcript + path recording). From 000cbecdf20a504f58c9afcf8a4eba54bb2c439c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:05:40 -0700 Subject: [PATCH 02/38] agent-session SoT: iOS re-pulls the session list on foreground (Slice A) Completes Slice A's client side. The list seed via `source.sessions(...)` is already an authoritative pull that re-runs on reconnect (the connection epoch in `chatRefreshKey`). Add a foreground epoch so returning from `.background` re-subscribes and re-pulls: pushes are best-effort and can be dropped while the app is suspended, so on foreground we re-read the host's authoritative list rather than trust that every push arrived. Transient `.inactive` (control center, a banner) does not churn the subscription; only real background does. Pairs with the version-gated reducer so a pull that races a late push converges. The single-session `mobile.chat.session` pull primitive remains available for finer-grained version-gap healing in the conversation view. Verified: CmuxMobileShellUI builds for iOS Simulator (BUILD SUCCEEDED). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WorkspaceDetailView.swift | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 31312dff52fe..039eba832212 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -65,6 +65,11 @@ struct WorkspaceDetailView: View { @State private var chatSessions: [ChatSessionDescriptor] = [] /// Per-session composer drafts, surviving toggles back to the terminal. @State private var chatDrafts: [String: String] = [:] + /// App lifecycle phase. Returning from `.background` re-pulls the session + /// list: pushes are best-effort and can be dropped while suspended, so on + /// foreground we re-read the host's authoritative list rather than trust + /// that every push arrived. + @Environment(\.scenePhase) private var scenePhase #endif private var selectedTerminal: MobileTerminalPreview? { @@ -211,9 +216,19 @@ struct WorkspaceDetailView: View { pinnedChatSessionID = isChatMode ? chosenChatSession?.id : nil } - /// Identity for the session refetch: workspace plus connection epoch. + /// Identity for the session refetch: workspace, connection epoch, and a + /// foreground epoch. A change re-runs `.task(id:)`, which re-subscribes to + /// the push stream and re-pulls the authoritative session list. + /// + /// The foreground epoch flips only on `.background` (not transient + /// `.inactive` like control center or a banner), so a real + /// background-then-foreground re-pulls while momentary inactivity does not + /// churn the subscription. `.background` tears the stream down to save + /// battery; returning to the foreground re-establishes and reconciles. private var chatRefreshKey: String { - "\(workspace.id.rawValue)#\(store.connectionState == .connected ? 1 : 0)" + let connected = store.connectionState == .connected ? 1 : 0 + let foreground = scenePhase == .background ? 0 : 1 + return "\(workspace.id.rawValue)#\(connected)#\(foreground)" } /// Keeps the chat-capable session list current while this workspace is From edc04ec808a8a53d02db90d4bbd9fa1254c65c5b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:10:57 -0700 Subject: [PATCH 03/38] agent-session SoT: deterministic process-exit backstop (Slice B) Replace the per-`sessions()` `kill(pid,0)` polling sweep with an event-driven `DispatchSourceProcess` (`.exit`) watcher per agent pid. cmux does not own a `Process` handle for a terminal agent (it is a child in the pty), so the deterministic exit signal is a process source on the pid cmux already knows from hook events / the store. On exit, the session flips to `.ended` on the main actor, but only if the exited pid is still the record's current pid, so a `claude --resume` under a new pid is never ended by its predecessor's exit. - `syncProcessExitWatch(for:)` reconciles the watcher with the record's pid at every store path (idempotent; cancels on pid change / clear / end). A pid already dead at registration ends the session on a fresh main-actor turn rather than waiting for an `.exit` that never comes. - `ended` stays retained: the GUI keeps showing the session and the input bar disables; only the watcher is torn down. - `sessions()` no longer sweeps on every read; the per-bound-session `kill(pid,0)` guard in `liveSession` stays as a cheap correctness backstop. A watcher unit test needs a real child process (timing-dependent, app test target only), so this is verified by build + dogfood rather than a flaky unit test. macOS app builds (tag agentsot). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 67 +++++++++++++++---- docs/agent-session-tracking-spec.md | 31 ++++++--- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 6393f970e011..14c1ad91c8bb 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -35,6 +35,14 @@ final class AgentChatSessionRegistry { record.version = next } + /// Per-session process-exit watchers, keyed by session id, each tagged with + /// the pid it watches. A `DispatchSourceProcess` (`.exit`) fires exactly + /// when the agent process dies (crash, kill, closed terminal), so the + /// session flips to `.ended` deterministically without a `SessionEnd` hook + /// and without polling `kill(pid,0)` on every read. `DispatchSource` is an + /// event source, not a timer, and is cancellable. + private var exitWatchers: [String: (pid: Int, source: DispatchSourceProcess)] = [:] + /// Creates a registry. /// /// - Parameter hookStore: Reader for the per-agent hook session stores. @@ -48,24 +56,55 @@ final class AgentChatSessionRegistry { /// - Parameter workspaceID: Workspace UUID string filter, or `nil`. /// - Returns: Matching records. func sessions(workspaceID: String?) -> [AgentChatSessionRecord] { - sweepDeadProcesses() return records.values .filter { workspaceID == nil || $0.workspaceID == workspaceID } .sorted { $0.lastActivityAt > $1.lastActivityAt } } - /// Marks sessions whose agent process died without a SessionEnd hook - /// (crash, kill, closed terminal) as ended, so a missing Stop hook - /// cannot wedge a session in "working" forever. - private func sweepDeadProcesses() { - for (sessionID, record) in records { - guard record.state != .ended, let pid = record.pid else { continue } - // ESRCH means the process is gone; EPERM means it exists but is - // not signalable, which still counts as alive. - if kill(pid_t(pid), 0) != 0, errno == ESRCH { - update(sessionID: sessionID) { $0.state = .ended } - } + /// Reconciles the session's exit watcher with its current pid. Called from + /// every record-store path, so a watcher exists exactly while a session has + /// a live pid and is cancelled when the pid changes, clears, or the session + /// ends. Idempotent: a no-op when already watching the right pid. + /// + /// A process that is already gone at registration (the app was off while it + /// died) would never produce an `.exit` event, so that case ends the + /// session on a fresh main-actor turn rather than registering a watcher. + private func syncProcessExitWatch(for record: AgentChatSessionRecord) { + let sessionID = record.sessionID + if let existing = exitWatchers[sessionID], existing.pid == record.pid { + return + } + exitWatchers[sessionID]?.source.cancel() + exitWatchers[sessionID] = nil + guard record.state != .ended, let pid = record.pid else { return } + // ESRCH means the process is already gone; EPERM means it exists but is + // not signalable, which still counts as alive. + if kill(pid_t(pid), 0) != 0, errno == ESRCH { + Task { @MainActor [weak self] in self?.handleProcessExit(sessionID: sessionID, pid: pid) } + return + } + let source = DispatchSource.makeProcessSource( + identifier: pid_t(pid), + eventMask: .exit, + queue: .global(qos: .utility) + ) + source.setEventHandler { [weak self] in + Task { @MainActor in self?.handleProcessExit(sessionID: sessionID, pid: pid) } + } + exitWatchers[sessionID] = (pid: pid, source: source) + source.resume() + } + + /// Flips a session to `.ended` because its agent process exited. Ignores a + /// stale fire: the session may have resumed under a new pid (`claude + /// --resume`), and the predecessor's exit must not end the live session. + /// `ended` is retained (the GUI stays shown, the input bar disables); only + /// the watcher is torn down. + private func handleProcessExit(sessionID: String, pid: Int) { + guard let record = records[sessionID], record.pid == pid, record.state != .ended else { + return } + update(sessionID: sessionID) { $0.state = .ended } } /// One session's record. @@ -137,6 +176,7 @@ final class AgentChatSessionRegistry { mutate(&record) stampVersion(&record) records[sessionID] = record + syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) } @@ -181,6 +221,7 @@ final class AgentChatSessionRegistry { ) stampVersion(&record) records[entry.sessionID] = record + syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: nil, current: record) } } @@ -228,6 +269,7 @@ final class AgentChatSessionRegistry { ) stampVersion(&record) records[sessionID] = record + syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: nil, current: record) onRecordChanged?(record, nil) return record @@ -295,6 +337,7 @@ final class AgentChatSessionRegistry { record.state = Self.nextState(previous: record.state, event: event) stampVersion(&record) records[sessionID] = record + syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) return record diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index 20010b05f1f6..d9f0366c4d57 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -305,16 +305,27 @@ already exists, so the remaining work is additive-then-subtractive, in reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff (runtime PRs do not merge before dogfood approval). -- **Slice A — Versioning + pull snapshot (additive, no deletes).** Add a - monotonic `version` to each session descriptor, bumped on every registry - change. `mobile.chat.sessions` carries `version`; add `mobile.chat.session` - (single-session snapshot pull). iOS reconciles by version and pulls on - (re)connect / foreground / version gap / manual refresh. Pure reliability win, - no behavior removed. Lowest risk, ships first. -- **Slice B — Owned-process-exit backstop.** Observe the agent pid cmux owns via - a termination handler; deterministic `ended` without the `sessionEnd` hook. - Keep `ended` retained (disables input bar, stays visible). Remove the - `kill(pid,0)` polling sweep once the handler covers it. +- **Slice A — Versioning + pull snapshot (additive, no deletes). DONE.** Added a + monotonic `version` to each session descriptor, bumped at one registry + chokepoint. `mobile.chat.sessions` carries `version`; added + `mobile.chat.session` (single-session snapshot pull). Reducer version-gates + upserts (+test). iOS re-pulls the list on (re)connect and foreground (the + `chatRefreshKey` connection + foreground epochs); `session(sessionID:)` pull + primitive available for finer version-gap healing. Verified: shared pkg +128 + tests, iOS shell + UI build, macOS app builds. +- **Slice B — Deterministic process-exit backstop.** cmux does NOT own a + `Process` handle for a terminal agent (the agent is a child in the pty), so + there is no `terminationHandler` to hook. The deterministic signal is a + `DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit)` watcher + on the agent pid cmux already knows (from hook events / the store). On `.exit`, + hop to the main actor and flip to `.ended` ONLY if the exited pid is still the + record's current pid (so a `claude --resume` under a new pid is not ended by + its predecessor's exit). Register/refresh the watcher wherever a record's pid + is set. Replaces the per-`sessions()` `kill(pid,0)` polling sweep with an + event-driven transition; keep the one-shot `kill(pid,0)` seed-time check. + `ended` stays retained (disables the input bar, stays visible). `DispatchSource` + is an event source, not a timer, so it does not fall under the asyncAfter ban + and is cancellable. - **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor` (interim, before it is deleted) and assert no `Data(contentsOf:)` / `JSONSerialization` / `Codable` decode on the main actor in this subsystem. From 775ce2fc3572924a71ac0d3a8847d424daf27f01 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:15:40 -0700 Subject: [PATCH 04/38] agent-session SoT spec: mark Slices A+B done; note ended-input-bar UI already exists Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-session-tracking-spec.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index d9f0366c4d57..0ee4904eb4a9 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -313,7 +313,13 @@ reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff `chatRefreshKey` connection + foreground epochs); `session(sessionID:)` pull primitive available for finer version-gap healing. Verified: shared pkg +128 tests, iOS shell + UI build, macOS app builds. -- **Slice B — Deterministic process-exit backstop.** cmux does NOT own a +- **Slice B — Deterministic process-exit backstop. DONE.** Implemented as + described below. Note: the iOS GUI ALREADY disables the input bar on `ended` + (`ChatComposerView` shows `endedRow` instead of the field; `ChatScreen` hides + the composer when `agentState == .ended`). That feature only needed `ended` + set RELIABLY, which this watcher now guarantees. So the user's "track ended to + disable the input bar" requirement is satisfied by existing UI + Slice B; no + new iOS UI needed. Design: cmux does NOT own a `Process` handle for a terminal agent (the agent is a child in the pty), so there is no `terminationHandler` to hook. The deterministic signal is a `DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit)` watcher From 1392e7e6e94f01f91f766fa2c9d2530d246a87cf Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:31:40 -0700 Subject: [PATCH 05/38] agent-session SoT: delete title/mtime detection heuristics (Slice D) Remove the unreliable agent-session detection layer (terminal-TITLE matching and the newest-.jsonl-by-MTIME scan, plus their claim/forced-retry/provisional machinery) while preserving the reliable path: hook events, the hook-store as cmux-written persistence/seed, and transcript resolution keyed by the exact recorded path or session id. Dropping detection of agents that never fire a hook is intended. Deleted: - Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift - cmuxTests/AgentChatTranscriptResolverTests.swift (only covered newestClaudeTranscript) - Resolver: newestClaudeTranscript, cwdCandidates, claudeTranscriptTitle(at:)/(in:), normalizedClaudeTitle + title-read constants - Service: adoptDetectedClaudeSession, private newestClaudeTranscript, observeAgentTitleChanges, ghosttyTitleSubscription, titleAdoptionHandler, all title-detection state vars + constants, provisional/title-key helpers, the PendingTitleChange/ClaudeTranscriptResolutionKey typealiases, the provisional branch in history(), and the clearTitleDetectionState call. start(adoptDetectedAgentSession:) -> start() (just seedFromHookStores). - Registry: claimedSessionIDs(), adoptDetectedSession() - TerminalController+MobileChat: adoptDetectedAgentSession(s) variants; v2MobileChatSessions now just lists registry sessions filtered by mobileChatBindingIsCurrentAgent. - TerminalController+MobileWorkspaceList: the adoptDetectedAgentSessions calls - AppDelegate: start() no-arg call site Kept (reliable): hook path, hook-store seed/refresh/adoptBindings, transcript resolution by recorded path + claudeFallbackPath/codexFallbackPath, encodeClaudeProjectDir, the GhosttyTitleChange(+Subscription) types (used for tab titles), and Slice A/B work. RestorableAgentSession.swift's newestClaudeTranscript is KEPT: it is the session-restore mechanism keyed by the recorded session id (workflow-container resolution), not the unreliable mobile-chat detection heuristic. Verified: CmuxAgentChat builds + 128 tests pass; macOS app Build complete (tag agentsotd); iOS CmuxMobileShellUI BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AppDelegate.swift | 2 +- .../AgentChat/AgentChatSessionRegistry.swift | 56 ---- .../AgentChatTranscriptResolver.swift | 188 -------------- ...ChatTranscriptService+TitleDetection.swift | 242 ------------------ .../AgentChatTranscriptService.swift | 213 +-------------- Sources/TerminalController+MobileChat.swift | 87 ------- ...rminalController+MobileWorkspaceList.swift | 7 - cmux.xcodeproj/project.pbxproj | 8 - .../AgentChatTranscriptResolverTests.swift | 122 --------- docs/agent-session-tracking-spec.md | 12 +- 10 files changed, 14 insertions(+), 923 deletions(-) delete mode 100644 Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift delete mode 100644 cmuxTests/AgentChatTranscriptResolverTests.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cc1385f2aa4d..e336116df655 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2036,7 +2036,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent auth.start() ensureMobileWorkspaceListObserver(for: tabManager) MobileTerminalRenderObserver.shared.start() - agentChatTranscriptService.start { TerminalController.shared.adoptDetectedAgentSession(titleChange: $0) } + agentChatTranscriptService.start() installMobileHostSettingsObserver() scheduleGhosttyCrashBreadcrumbIfNeeded(notificationStore: notificationStore) startPaneMemoryGuardrailIfNeeded(notificationStore: notificationStore) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 14c1ad91c8bb..ba8eb9bb749a 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -136,14 +136,6 @@ final class AgentChatSessionRegistry { return nil } - /// Every session id the registry already tracks. Title-detected adoption - /// passes this to the transcript resolver so a second hook-bypassed claude - /// in the same directory resolves to a *different* (unclaimed) transcript - /// instead of colliding on the newest file. - func claimedSessionIDs() -> Set { - Set(records.keys) - } - /// Re-reads the hook store for one session and adopts its bindings, /// for callers that just failed to resolve the recorded terminal (an /// app relaunch regenerates panel UUIDs; the store is rewritten by @@ -227,54 +219,6 @@ final class AgentChatSessionRegistry { } } - /// Registers a coding-agent session cmux detected by terminal title or - /// launch metadata rather than by an agent hook (e.g. an agent launched - /// through a shell wrapper that bypasses cmux's hook injection). Without - /// a hook we never learned the agent's session id, so the caller resolves - /// the transcript by working directory and passes its filename stem as - /// the id. - /// - /// No-op (returns the existing record) when a session with that id is - /// already known, or when any live session is already bound to the same - /// surface — a hook-registered record is authoritative and must not be - /// shadowed. A brand-new record fires `onRecordChanged` with `nil`, so it - /// pushes to listening clients exactly like a hook-created session. - /// - /// - Returns: The adopted or pre-existing record. - @discardableResult - func adoptDetectedSession( - sessionID: String, - agentKind: ChatAgentKind, - workspaceID: String, - surfaceID: String, - workingDirectory: String?, - transcriptPath: String?, - at timestamp: Date - ) -> AgentChatSessionRecord { - if let existing = records[sessionID] { return existing } - if let bound = liveSession(surfaceID: surfaceID) { - return bound - } - var record = AgentChatSessionRecord( - sessionID: sessionID, - agentKind: agentKind, - workspaceID: workspaceID, - surfaceID: surfaceID, - workingDirectory: workingDirectory, - transcriptPath: transcriptPath, - state: .idle, - lastActivityAt: timestamp, - title: nil, - pid: nil - ) - stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) - onRecordChanged?(record, nil) - return record - } - /// Ingests one hook event: creates or refreshes the session record and /// derives the live state transition. /// diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 19742824c932..601715a6346e 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -8,9 +8,6 @@ import Foundation /// codex: rollout filename containing the session id). struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL - private static let claudeTranscriptTitleReadLimit = 1_048_576 - private static let claudeTranscriptTitleChunkSize = 64 * 1024 - private static let claudeTranscriptTitleMaxLineBytes = 256 * 1024 /// Creates a resolver. /// @@ -42,122 +39,6 @@ struct AgentChatTranscriptResolver: Sendable { } } - /// The newest Claude transcript in a working directory's project dir, - /// with its session id (the filename stem). - /// - /// Used to adopt a Claude session cmux detected by terminal title but - /// that never ran a hook (e.g. launched through a shell wrapper that - /// bypasses cmux's hook injection), so we never learned its session id. - /// The newest `.jsonl` in the cwd's project dir is the live conversation. - /// - /// - Parameters: - /// - workingDirectory: The agent's working directory. - /// - excludingSessionIDs: Session ids already bound to another surface; - /// their transcripts are skipped so two hook-bypassed claudes in the - /// same directory each adopt a distinct conversation instead of both - /// resolving to the single newest file (and the second getting nothing). - /// - Returns: The session id and absolute transcript path of the newest - /// unclaimed transcript, or `nil` when none is found. - func newestClaudeTranscript( - workingDirectory: String, - excludingSessionIDs: Set = [], - titleHint: String? = nil - ) -> (sessionID: String, path: String)? { - guard !Task.isCancelled else { return nil } - let fileManager = FileManager.default - // The home project dir is a junk drawer of every home-rooted claude - // conversation, so newest-by-mtime there is almost never *this* - // terminal's session. Refuse title-detected adoption from $HOME; a - // hooked claude in ~ still resolves by its exact session id via - // `claudeFallbackPath`, so only the fuzzy path is blocked. - let home = homeDirectory.resolvingSymlinksInPath().path - // claude encodes the project dir from the cwd it sees, which is the - // symlink-resolved path (getcwd → /private/tmp), while a panel's cwd - // is often the unresolved form (/tmp). Try every form so a /tmp-rooted - // terminal still finds its /private/tmp transcript dir. - let candidates = Self.cwdCandidates(workingDirectory) - .filter { URL(fileURLWithPath: $0).resolvingSymlinksInPath().path != home } - let normalizedTitleHint = Self.normalizedClaudeTitle(titleHint) - for cwd in candidates { - guard !Task.isCancelled else { return nil } - let projectDir = RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd) - let dir = homeDirectory - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent(projectDir, isDirectory: true) - guard let entries = try? fileManager.contentsOfDirectory( - at: dir, - includingPropertiesForKeys: [.contentModificationDateKey], - options: [.skipsHiddenFiles] - ) else { continue } - var transcriptCandidates: [(url: URL, date: Date, title: String?)] = [] - for url in entries where url.pathExtension == "jsonl" { - guard !Task.isCancelled else { return nil } - let sessionID = url.deletingPathExtension().lastPathComponent - guard !excludingSessionIDs.contains(sessionID) else { continue } - transcriptCandidates.append(( - url: url, - date: (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast, - title: Self.claudeTranscriptTitle(at: url) - )) - } - let newest: URL? - if let normalizedTitleHint { - let exactTitleCandidates = transcriptCandidates - .filter { Self.normalizedClaudeTitle($0.title) == normalizedTitleHint } - let untitledCandidates = transcriptCandidates.filter { $0.title == nil } - let newestExact = exactTitleCandidates.max { $0.date < $1.date } - let newestUntitled = untitledCandidates.max { $0.date < $1.date } - if let newestExact, - let newestUntitled, - newestUntitled.date > newestExact.date { - return nil - } - newest = (newestExact ?? newestUntitled)?.url - } else { - // A generic "Claude Code" title cannot identify one of several - // same-cwd sessions. Avoid stealing a transcript that already - // has a conversation title; a later title-change scan can bind - // it to the matching terminal. - newest = transcriptCandidates - .filter { $0.title == nil } - .max { $0.date < $1.date }? - .url - } - if let newest { - return (sessionID: newest.deletingPathExtension().lastPathComponent, path: newest.path) - } - } - return nil - } - - /// Every cwd form claude might have encoded its project dir from, most - /// specific first. `URL.resolvingSymlinksInPath()` is not enough on its - /// own: across macOS versions it strips a leading `/private` but does NOT - /// add one (so `/tmp` stays `/tmp` instead of becoming `/private/tmp`), - /// yet claude's `getcwd` returns the `/private`-prefixed form. So toggle - /// the `/private` prefix explicitly on both the raw and symlink-resolved - /// paths, deduped in order. Existence-free, so it works before the dir is - /// created. - static func cwdCandidates(_ workingDirectory: String) -> [String] { - var seen = Set() - var result: [String] = [] - func add(_ path: String) { - guard !path.isEmpty, seen.insert(path).inserted else { return } - result.append(path) - } - let privateRoot = "/private" - for base in [workingDirectory, URL(fileURLWithPath: workingDirectory).resolvingSymlinksInPath().path] { - add(base) - if base.hasPrefix(privateRoot + "/") { - add(String(base.dropFirst(privateRoot.count))) - } else if base.hasPrefix("/") { - add(privateRoot + base) - } - } - return result - } - private func claudeFallbackPath(record: AgentChatSessionRecord) -> String? { let fileManager = FileManager.default guard let cwd = record.workingDirectory else { return nil } @@ -193,73 +74,4 @@ struct AgentChatTranscriptResolver: Sendable { } return nil } - - private static func normalizedClaudeTitle(_ title: String?) -> String? { - guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty else { - return nil - } - while let first = title.first, !first.isLetter && !first.isNumber { - title.removeFirst() - title = title.trimmingCharacters(in: .whitespacesAndNewlines) - } - let normalized = title.lowercased() - guard !normalized.isEmpty, - normalized != "claude code", - !normalized.hasPrefix("claude ·") else { - return nil - } - return normalized - } - - private static func claudeTranscriptTitle(at url: URL) -> String? { - guard let handle = try? FileHandle(forReadingFrom: url) else { - return nil - } - defer { try? handle.close() } - - var buffered = Data() - var bytesRead = 0 - var droppingOversizedLine = false - while bytesRead < claudeTranscriptTitleReadLimit { - guard !Task.isCancelled else { return nil } - let readSize = min(claudeTranscriptTitleChunkSize, claudeTranscriptTitleReadLimit - bytesRead) - guard let chunk = try? handle.read(upToCount: readSize), - !chunk.isEmpty else { - break - } - bytesRead += chunk.count - buffered.append(chunk) - - while let newlineIndex = buffered.firstIndex(of: 0x0A) { - let lineData = Data(buffered[.. claudeTranscriptTitleMaxLineBytes { - buffered.removeAll(keepingCapacity: true) - droppingOversizedLine = true - } - } - guard !droppingOversizedLine else { - return nil - } - return claudeTranscriptTitle(in: buffered) - } - - private static func claudeTranscriptTitle(in lineData: Data) -> String? { - guard lineData.range(of: Data(#""ai-title""#.utf8)) != nil, - let object = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], - object["type"] as? String == "ai-title" else { - return nil - } - return object["aiTitle"] as? String - } } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift deleted file mode 100644 index 1b49d50a6a49..000000000000 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService+TitleDetection.swift +++ /dev/null @@ -1,242 +0,0 @@ -import Foundation - -extension AgentChatTranscriptService { - func scheduleTitleDetectedAdoption(_ change: GhosttyTitleChange) { - let surfaceID = change.surfaceId.uuidString - guard let titleKey = Self.claudeTitleDetectionKey(change.title) else { - clearTitleDetectionState(surfaceID: surfaceID) - return - } - if pendingTitleChanges[surfaceID]?.titleKey == titleKey { - return - } - if pendingTitleChanges[surfaceID] == nil, - deliveredTitleKeys[surfaceID] == titleKey, - registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { - return - } - - pendingTitleChanges[surfaceID] = (change: change, titleKey: titleKey) - titleChangeCoalescer.signal { [weak self] in - self?.flushTitleDetectedAdoptions() - } - } - - func flushTitleDetectedAdoptions() { - guard !pendingTitleChanges.isEmpty else { - return - } - let pendingBySurface = pendingTitleChanges - pendingTitleChanges.removeAll(keepingCapacity: true) - for (surfaceID, pending) in pendingBySurface { - if titleAdoptionHandler?(pending.change) == true, - registry.liveSession(surfaceID: surfaceID)?.transcriptPath != nil { - deliveredTitleKeys[surfaceID] = pending.titleKey - } - } - } - - func clearTitleDetectionState( - surfaceID: String, - releaseTranscriptClaims: Bool = false - ) { - pendingTitleChanges.removeValue(forKey: surfaceID) - deliveredTitleKeys.removeValue(forKey: surfaceID) - transcriptResolutionTasks[surfaceID]?.cancel() - transcriptResolutionTasks[surfaceID] = nil - transcriptResolutionKeys.removeValue(forKey: surfaceID) - transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) - if releaseTranscriptClaims { - claimedDetectedTranscriptSessionIDsBySurfaceID.removeValue(forKey: surfaceID) - } - detectionScanAt.removeValue(forKey: surfaceID) - } - - func scheduleClaudeTranscriptResolution( - workspaceID: String, - workingDirectory: String, - surfaceID: String, - excludingSessionID: String?, - titleHint: String?, - forceScan: Bool - ) { - let now = Date() - if !forceScan, - let lastScan = detectionScanAt[surfaceID], - now.timeIntervalSince(lastScan) < Self.detectionScanThrottle { - return - } - - var claimed = registry.claimedSessionIDs() - .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) - if let excludingSessionID { - claimed.remove(excludingSessionID) - } - let key: ClaudeTranscriptResolutionKey = ( - workingDirectory: workingDirectory, - claimedSessionIDs: claimed, - titleKey: Self.specificClaudeTitleKey(titleHint), - forceScan: forceScan - ) - if let currentKey = transcriptResolutionKeys[surfaceID], - currentKey == key { - return - } - - detectionScanAt[surfaceID] = now - transcriptResolutionKeys[surfaceID] = key - if !forceScan { - transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) - } - transcriptResolutionTasks[surfaceID]?.cancel() - let resolver = self.resolver - #if compiler(>=6.2) - let resolveOperation: @concurrent @Sendable () async -> (sessionID: String, path: String)? = { - [resolver, workingDirectory, claimed, titleHint] in - resolver.newestClaudeTranscript( - workingDirectory: workingDirectory, - excludingSessionIDs: claimed, - titleHint: titleHint - ) - } - #else - let resolveOperation: @Sendable () async -> (sessionID: String, path: String)? = { - [resolver, workingDirectory, claimed, titleHint] in - resolver.newestClaudeTranscript( - workingDirectory: workingDirectory, - excludingSessionIDs: claimed, - titleHint: titleHint - ) - } - #endif - let scanTask = Task.detached(priority: .utility, operation: resolveOperation) - transcriptResolutionTasks[surfaceID] = Task { @MainActor [ - weak self, - scanTask, - key, - workspaceID, - workingDirectory, - surfaceID, - titleHint - ] in - let resolved = await withTaskCancellationHandler { - await scanTask.value - } onCancel: { - scanTask.cancel() - } - guard !Task.isCancelled else { return } - self?.applyClaudeTranscriptResolution( - resolved, - key: key, - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - titleHint: titleHint - ) - } - } - - func applyClaudeTranscriptResolution( - _ resolved: (sessionID: String, path: String)?, - key: ClaudeTranscriptResolutionKey, - workspaceID: String, - workingDirectory: String, - surfaceID: String, - titleHint: String? - ) { - guard let currentKey = transcriptResolutionKeys[surfaceID], - currentKey == key else { - return - } - transcriptResolutionTasks[surfaceID] = nil - transcriptResolutionKeys[surfaceID] = nil - - guard let resolved else { return } - guard !activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID).contains(resolved.sessionID) else { - scheduleForcedClaudeTranscriptRetry( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint - ) - return - } - if let claimed = registry.record(sessionID: resolved.sessionID), - claimed.surfaceID != nil, - claimed.surfaceID != surfaceID { - scheduleForcedClaudeTranscriptRetry( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: registry.liveSession(surfaceID: surfaceID)?.sessionID, - titleHint: titleHint - ) - return - } - - detectionScanAt.removeValue(forKey: surfaceID) - if let bound = registry.liveSession(surfaceID: surfaceID) { - guard bound.transcriptPath == nil else { return } - registry.update(sessionID: bound.sessionID) { record in - record.workspaceID = workspaceID - record.surfaceID = surfaceID - record.workingDirectory = workingDirectory - record.transcriptPath = resolved.path - } - claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) - transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) - return - } - - let adopted = registry.adoptDetectedSession( - sessionID: resolved.sessionID, - agentKind: .claude, - workspaceID: workspaceID, - surfaceID: surfaceID, - workingDirectory: workingDirectory, - transcriptPath: resolved.path, - at: Date() - ) - if adopted.surfaceID == surfaceID, - adopted.transcriptPath == resolved.path { - claimDetectedTranscriptSessionID(resolved.sessionID, surfaceID: surfaceID) - transcriptResolutionForcedRetryCounts.removeValue(forKey: surfaceID) - } - } - - func scheduleForcedClaudeTranscriptRetry( - workspaceID: String, - workingDirectory: String, - surfaceID: String, - excludingSessionID: String?, - titleHint: String? - ) { - let retryCount = transcriptResolutionForcedRetryCounts[surfaceID, default: 0] - guard retryCount < Self.maxTranscriptResolutionForcedRetries else { - return - } - transcriptResolutionForcedRetryCounts[surfaceID] = retryCount + 1 - scheduleClaudeTranscriptResolution( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: excludingSessionID, - titleHint: titleHint, - forceScan: true - ) - } - - func activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID surfaceID: String) -> Set { - var claimed = Set() - for (claimedSurfaceID, sessionIDs) in claimedDetectedTranscriptSessionIDsBySurfaceID - where claimedSurfaceID != surfaceID { - claimed.formUnion(sessionIDs) - } - return claimed - } - - func claimDetectedTranscriptSessionID(_ sessionID: String, surfaceID: String) { - claimedDetectedTranscriptSessionIDsBySurfaceID[surfaceID, default: []].insert(sessionID) - } -} diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 45a242c7ebea..52e5466c246a 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -18,24 +18,6 @@ final class AgentChatTranscriptService { /// explicit history request retries, so per-hook-event resolution /// failures don't rescan the filesystem during tool storms. private var failedResolutions: Set = [] - /// Last time `adoptDetectedClaudeSession` ran a filesystem scan for a - /// surface that had no session yet, keyed by surface id. Bounds transcript - /// resolution scheduling to once per `detectionScanThrottle` while a - /// title-detected claude has not yet written its transcript; a successful - /// adoption removes the entry. - var detectionScanAt: [String: Date] = [:] - private var ghosttyTitleSubscription: GhosttyTitleChangeSubscription? - var pendingTitleChanges: [String: PendingTitleChange] = [:] - var deliveredTitleKeys: [String: String] = [:] - var transcriptResolutionTasks: [String: Task] = [:] - var transcriptResolutionKeys: [String: ClaudeTranscriptResolutionKey] = [:] - var transcriptResolutionForcedRetryCounts: [String: Int] = [:] - var claimedDetectedTranscriptSessionIDsBySurfaceID: [String: Set] = [:] - var titleAdoptionHandler: (@MainActor (GhosttyTitleChange) -> Bool)? - let titleChangeCoalescer = NotificationBurstCoalescer(delay: 0.25) - static let detectionScanThrottle: TimeInterval = 4 - static let maxTranscriptResolutionForcedRetries = 3 - private static let provisionalClaudeSessionIDPrefix = "detected-claude-surface-" /// Creates the service with a hook-store-backed registry. /// @@ -61,27 +43,10 @@ final class AgentChatTranscriptService { } /// Seeds the session registry from the on-disk hook stores. Call once - /// at app startup. - /// - /// - Parameter adoptDetectedAgentSession: Composition-root callback that - /// adopts a title-detected agent for the surface whose title changed, - /// returning whether the surface was resolved and adoption was queued. - func start(adoptDetectedAgentSession: @escaping @MainActor (GhosttyTitleChange) -> Bool) { - guard ghosttyTitleSubscription == nil else { return } - titleAdoptionHandler = adoptDetectedAgentSession + /// at app startup. Sessions are tracked only via the reliable hook-event + /// path thereafter; cmux does not detect agents that never fire a hook. + func start() { registry.seedFromHookStores() - observeAgentTitleChanges() - } - - /// Watches terminal title changes so a coding agent launched without a - /// hook (e.g. via a shell wrapper that bypasses cmux's hook injection) is - /// adopted the instant its terminal title becomes the agent's (e.g. - /// "✳ Claude Code"), not only when the workspace is next opened. Adoption - /// emits a descriptor change, which pushes the toggle to listening phones. - private func observeAgentTitleChanges() { - ghosttyTitleSubscription = GhosttyTitleChangeSubscription { [weak self] change in - self?.scheduleTitleDetectedAdoption(change) - } } /// Ingests one hook event (called from the socket dispatch path). @@ -123,74 +88,6 @@ final class AgentChatTranscriptService { registry.sessions(workspaceID: workspaceID) } - /// Adopts a Claude session cmux detected by terminal title but that - /// never registered via a hook (e.g. launched through a shell wrapper - /// that bypasses cmux's hook injection), so it gains a chat session and - /// toggle like a hooked agent. Creates a provisional surface-keyed session - /// before Claude writes its transcript, then attaches the transcript to - /// that same session once it appears. - /// - /// - Parameters: - /// - workspaceID: The agent's workspace UUID string. - /// - surfaceID: The hosting terminal surface UUID string. - /// - workingDirectory: The agent's working directory. - /// - Returns: `true` when a session is present for the surface afterward. - @discardableResult - func adoptDetectedClaudeSession( - workspaceID: String, - surfaceID: String, - workingDirectory: String, - titleHint: String? = nil - ) -> Bool { - if let bound = registry.liveSession(surfaceID: surfaceID) { - if bound.workspaceID != workspaceID - || bound.surfaceID != surfaceID - || bound.workingDirectory != workingDirectory { - registry.update(sessionID: bound.sessionID) { record in - record.workspaceID = workspaceID - record.surfaceID = surfaceID - record.workingDirectory = workingDirectory - } - } - guard bound.transcriptPath == nil else { return true } - scheduleClaudeTranscriptResolution( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: bound.sessionID, - titleHint: titleHint, - forceScan: Self.isSpecificClaudeTitle(titleHint) - ) - return true - } - // A claude detected by title before it has written its transcript jsonl - // (the launch race) resolves to nothing. List-level adoption runs this - // on every workspace-list RPC and every "claude" title change across - // ALL workspaces, so without a throttle an un-resolvable surface would - // schedule fresh transcript resolution on each call during a title - // burst. Bound the off-main resolution to once per surface per window; - // a success clears the entry (and `liveSession` short-circuits forever - // after once the transcript is bound). - registry.adoptDetectedSession( - sessionID: Self.provisionalClaudeSessionID(surfaceID: surfaceID), - agentKind: .claude, - workspaceID: workspaceID, - surfaceID: surfaceID, - workingDirectory: workingDirectory, - transcriptPath: nil, - at: Date() - ) - scheduleClaudeTranscriptResolution( - workspaceID: workspaceID, - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: nil, - titleHint: titleHint, - forceScan: false - ) - return true - } - /// The registry record for a session (send path needs the terminal /// binding). /// @@ -220,28 +117,10 @@ final class AgentChatTranscriptService { // A user opening the chat is the right moment to retry a previously // failed transcript resolution. failedResolutions.remove(sessionID) - if record.transcriptPath == nil, - Self.isProvisionalClaudeSessionID(sessionID), - let workingDirectory = record.workingDirectory, - let surfaceID = record.surfaceID, - let resolved = newestClaudeTranscript( - workingDirectory: workingDirectory, - surfaceID: surfaceID, - excludingSessionID: sessionID, - titleHint: record.title, - forceScan: true - ) { - registry.update(sessionID: sessionID) { $0.transcriptPath = resolved.path } - } - guard let currentRecord = registry.record(sessionID: sessionID) else { return nil } - if currentRecord.transcriptPath == nil, - Self.isProvisionalClaudeSessionID(sessionID) { - return ChatHistoryPage(messages: [], hasMore: false) - } - guard let tailer = ensureTailer(for: currentRecord) else { return nil } + guard let tailer = ensureTailer(for: record) else { return nil } await tailer.start() let page = await tailer.history(beforeSeq: beforeSeq, limit: limit) - if currentRecord.title == nil, let title = await tailer.title { + if record.title == nil, let title = await tailer.title { registry.update(sessionID: sessionID) { $0.title = title } } return page @@ -261,7 +140,6 @@ final class AgentChatTranscriptService { entry["workspace_id"] = record.workspaceID entry["surface_id"] = record.surfaceID entry["transcript_path"] = record.transcriptPath - entry["is_provisional"] = Self.isProvisionalClaudeSessionID(record.sessionID) if let pid = record.pid { entry["pid"] = pid entry["pid_alive"] = kill(pid_t(pid), 0) == 0 @@ -272,14 +150,6 @@ final class AgentChatTranscriptService { // MARK: - Internals - typealias PendingTitleChange = (change: GhosttyTitleChange, titleKey: String) - typealias ClaudeTranscriptResolutionKey = ( - workingDirectory: String, - claimedSessionIDs: Set, - titleKey: String?, - forceScan: Bool - ) - @discardableResult private func ensureTailer(for record: AgentChatSessionRecord) -> AgentChatTranscriptTailer? { if let existing = tailers[record.sessionID] { @@ -287,9 +157,6 @@ final class AgentChatTranscriptService { } guard !failedResolutions.contains(record.sessionID) else { return nil } guard let path = resolver.transcriptPath(for: record) else { - if Self.isProvisionalClaudeSessionID(record.sessionID) { - return nil - } failedResolutions.insert(record.sessionID) return nil } @@ -350,9 +217,6 @@ final class AgentChatTranscriptService { let stateChanged = previous?.state != record.state let transcriptBecameAvailable = previous?.transcriptPath == nil && record.transcriptPath != nil if stateChanged, record.state == .ended { - if let surfaceID = record.surfaceID { - clearTitleDetectionState(surfaceID: surfaceID, releaseTranscriptClaims: true) - } if let tailer = tailers.removeValue(forKey: record.sessionID) { // The transcript can no longer grow; release the file watcher // and cache instead of holding them until app quit. Evicting @@ -390,73 +254,6 @@ final class AgentChatTranscriptService { MobileHostService.emitEvent(topic: Self.eventTopic, payload: payload) } - private func newestClaudeTranscript( - workingDirectory: String, - surfaceID: String, - excludingSessionID: String?, - titleHint: String?, - forceScan: Bool - ) -> (sessionID: String, path: String)? { - let now = Date() - if !forceScan, - let lastScan = detectionScanAt[surfaceID], - now.timeIntervalSince(lastScan) < Self.detectionScanThrottle { - return nil - } - detectionScanAt[surfaceID] = now - var claimed = registry.claimedSessionIDs() - .union(activeClaimedDetectedTranscriptSessionIDs(excludingSurfaceID: surfaceID)) - if let excludingSessionID { - claimed.remove(excludingSessionID) - } - return resolver.newestClaudeTranscript( - workingDirectory: workingDirectory, - excludingSessionIDs: claimed, - titleHint: titleHint - ) - } - - private static func provisionalClaudeSessionID(surfaceID: String) -> String { - provisionalClaudeSessionIDPrefix + surfaceID.lowercased() - } - - private static func isProvisionalClaudeSessionID(_ sessionID: String) -> Bool { - sessionID.hasPrefix(provisionalClaudeSessionIDPrefix) - } - - static func claudeTitleDetectionKey(_ title: String?) -> String? { - guard let title else { - return nil - } - let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) - guard title.lowercased().contains("claude") || trimmed.hasPrefix("✳") else { - return nil - } - return specificClaudeTitleKey(title) ?? "generic:claude" - } - - static func specificClaudeTitleKey(_ title: String?) -> String? { - guard var title = title?.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty else { - return nil - } - while let first = title.first, !first.isLetter && !first.isNumber { - title.removeFirst() - title = title.trimmingCharacters(in: .whitespacesAndNewlines) - } - let normalized = title.lowercased() - guard !normalized.isEmpty, - normalized != "claude code", - !normalized.hasPrefix("claude ·") else { - return nil - } - return "specific:\(normalized)" - } - - static func isSpecificClaudeTitle(_ title: String?) -> Bool { - specificClaudeTitleKey(title) != nil - } - /// Encodes a wire value into the `[String: Any]` payload shape the /// event fan-out expects. func wirePayload(_ value: T) -> [String: Any]? { diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 59aaf9af325a..28ce7ebf49bf 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -67,13 +67,6 @@ extension TerminalController { guard let service = agentChatTranscriptService else { return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } - // Register coding agents cmux detects by terminal title but that never - // ran a hook (e.g. launched through a shell wrapper that bypasses - // cmux's hook injection), so they get a chat session and toggle like - // hook-registered agents. - if let workspaceID { - adoptDetectedAgentSessions(workspaceID: workspaceID) - } let descriptors = service.sessionRecords(workspaceID: workspaceID) .filter { mobileChatBindingIsCurrentAgent($0) } .map(\.descriptor) @@ -109,86 +102,6 @@ extension TerminalController { return .ok(["session": encoded]) } - /// Scans a workspace's terminals for a running coding agent that has no - /// chat session yet (title- or launch-metadata-detected, no hook) and - /// adopts it. Adoption is a no-op once the surface has a session, so this - /// only touches the filesystem the first time an agent is seen. Called - /// both on a mobile session-list request and live from the terminal - /// title-change observer, so the toggle appears the moment an agent - /// launches, not only when the workspace is next opened. - func adoptDetectedAgentSessions(workspaceID: String) { - guard let resolved = mobileResolveWorkspaceAndSurface( - params: ["workspace_id": workspaceID], - requireTerminal: false - ) else { return } - adoptDetectedAgentSessions(workspace: resolved.workspace) - } - - /// Surface-scoped title-change adoption path. Unlike the workspace-list - /// sweep, this handles live terminal title churn and must avoid rescanning - /// every terminal in the workspace. - @discardableResult - func adoptDetectedAgentSession(titleChange: GhosttyTitleChange) -> Bool { - guard let resolved = mobileResolveWorkspaceAndSurface( - params: [ - "workspace_id": titleChange.tabId.uuidString, - "surface_id": titleChange.surfaceId.uuidString, - ], - requireTerminal: false - ), - let surfaceId = resolved.surfaceId, - let panel = resolved.workspace.terminalPanel(for: surfaceId) else { - return false - } - return adoptDetectedAgentSession( - workspace: resolved.workspace, - panel: panel, - title: titleChange.title - ) - } - - /// Workspace-typed core of ``adoptDetectedAgentSessions(workspaceID:)``, - /// for callers that already hold the `Workspace` (the workspace-list RPC - /// enumerates every workspace and adopts inline, so the toggle is known - /// before the user enters the workspace — no per-open resolution and no - /// pop-in). Each `adoptDetectedClaudeSession` short-circuits in memory - /// once the surface has a session, so a repeat scan of an already-adopted - /// workspace touches no filesystem. - func adoptDetectedAgentSessions(workspace: Workspace) { - for panel in workspace.panels.values.compactMap({ $0 as? TerminalPanel }) { - let title = workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle - adoptDetectedAgentSession(workspace: workspace, panel: panel, title: title) - } - } - - @discardableResult - private func adoptDetectedAgentSession( - workspace: Workspace, - panel: TerminalPanel, - title: String - ) -> Bool { - guard let service = agentChatTranscriptService else { return false } - let context = WorkspaceContentView.terminalAgentContext(panel: panel, workspace: workspace) - let normalizedTitle = title.lowercased() - // Claude is the case the wrapper-launched workflow hits; detect by - // launch metadata (hook PID key / initial command) or the live - // terminal title claude sets ("✳ Claude Code", then "✳ "). - let isClaude = TextBoxAgentDetection.isClaudeCode(context: context) - || normalizedTitle.contains("claude") - || title.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("✳") - guard isClaude else { return false } - let cwd = workspace.panelDirectories[panel.id] - ?? (panel.directory.isEmpty ? nil : panel.directory) - ?? (workspace.currentDirectory.isEmpty ? nil : workspace.currentDirectory) - guard let cwd, !cwd.isEmpty else { return false } - return service.adoptDetectedClaudeSession( - workspaceID: workspace.id.uuidString, - surfaceID: panel.id.uuidString, - workingDirectory: cwd, - titleHint: title - ) - } - /// `mobile.chat.history`: one transcript page for a session. func v2MobileChatHistory(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id") else { diff --git a/Sources/TerminalController+MobileWorkspaceList.swift b/Sources/TerminalController+MobileWorkspaceList.swift index e51b92b486c7..f2a69a8e2183 100644 --- a/Sources/TerminalController+MobileWorkspaceList.swift +++ b/Sources/TerminalController+MobileWorkspaceList.swift @@ -104,12 +104,6 @@ extension TerminalController { data: ["workspace_id": requestedWorkspaceID.uuidString] ) } - // Adopt any title-detected coding agent before serializing, so a - // hook-bypassed Claude registers and the phone can show the chat - // toggle as soon as the workspace rows refresh. - for workspace in visibleWorkspaces { - adoptDetectedAgentSessions(workspace: workspace) - } let scopedWorkspaces = visibleWorkspaces.map { workspace in mobileWorkspacePayload( workspace: workspace, @@ -154,7 +148,6 @@ extension TerminalController { ) ) for workspace in windowTabManager.tabs where seenWorkspaceIDs.insert(workspace.id).inserted { - adoptDetectedAgentSessions(workspace: workspace) flattened.append( mobileWorkspacePayload( workspace: workspace, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1ab8dbf6104f..524be4fc0464 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -13,8 +13,6 @@ ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000002 /* AgentChatSessionRecord.swift */; }; ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */; }; ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */; }; - A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */; }; - ACA7C4A70000000000000101 /* AgentChatTranscriptService+TitleDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */; }; ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */; }; ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */; }; A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000001 /* AgentExecutableResolver.swift */; }; @@ -1141,8 +1139,6 @@ ACA7C4A70000000000000002 /* AgentChatSessionRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRecord.swift"; sourceTree = ""; }; ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatSessionRegistry.swift"; sourceTree = ""; }; ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptResolver.swift"; sourceTree = ""; }; - A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatTranscriptResolverTests.swift; sourceTree = ""; }; - ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService+TitleDetection.swift"; sourceTree = ""; }; ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptService.swift"; sourceTree = ""; }; ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatTranscriptTailer.swift"; sourceTree = ""; }; A9F100000000000000000001 /* AgentExecutableResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentExecutableResolver.swift; sourceTree = ""; }; @@ -2311,7 +2307,6 @@ ACA7C4A70000000000000006 /* AgentChatTranscriptResolver.swift */, ACA7C4A70000000000000008 /* AgentChatSessionRegistry.swift */, ACA7C4A7000000000000000A /* AgentChatTranscriptTailer.swift */, - ACA7C4A70000000000000102 /* AgentChatTranscriptService+TitleDetection.swift */, ACA7C4A7000000000000000C /* AgentChatTranscriptService.swift */, ); name = AgentChat; @@ -3054,7 +3049,6 @@ D3610B010000000000000002 /* AgentSessionAutoResumeSettingsTests.swift */, D3610B020000000000000002 /* AgentSessionAutoResumeSwiftTests.swift */, A9E010000000000000000005 /* AgentExecutableResolverTests.swift */, - A9E010000000000000000010 /* AgentChatTranscriptResolverTests.swift */, A9E030000000000000000001 /* AgentSessionSocketSurfaceTests.swift */, A9E050000000000000000001 /* AgentSessionWebRendererTests.swift */, A9E040000000000000000001 /* CodexAppServerSessionTests.swift */, @@ -3737,7 +3731,6 @@ ACA7C4A70000000000000001 /* AgentChatSessionRecord.swift in Sources */, ACA7C4A70000000000000007 /* AgentChatSessionRegistry.swift in Sources */, ACA7C4A70000000000000005 /* AgentChatTranscriptResolver.swift in Sources */, - ACA7C4A70000000000000101 /* AgentChatTranscriptService+TitleDetection.swift in Sources */, ACA7C4A7000000000000000B /* AgentChatTranscriptService.swift in Sources */, ACA7C4A70000000000000009 /* AgentChatTranscriptTailer.swift in Sources */, A9F200000000000000000001 /* AgentExecutableResolver.swift in Sources */, @@ -4450,7 +4443,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - A9E020000000000000000010 /* AgentChatTranscriptResolverTests.swift in Sources */, A9E020000000000000000005 /* AgentExecutableResolverTests.swift in Sources */, D36A00020000000000000001 /* AgentHibernationTests.swift in Sources */, D3610B010000000000000001 /* AgentSessionAutoResumeSettingsTests.swift in Sources */, diff --git a/cmuxTests/AgentChatTranscriptResolverTests.swift b/cmuxTests/AgentChatTranscriptResolverTests.swift deleted file mode 100644 index 88511507bdd7..000000000000 --- a/cmuxTests/AgentChatTranscriptResolverTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation -import Testing - -#if canImport(cmux_DEV) - @testable import cmux_DEV -#elseif canImport(cmux) - @testable import cmux -#endif - -/// Unit tests for the title-detected adoption transcript resolver. These cover -/// the subtle, silently-regressing cases that confounded earlier debugging: -/// the cwd-collision disambiguation (excludingSessionIDs) and the $HOME -/// junk-drawer guard. The resolver takes an injectable home directory, so the -/// whole thing runs against a temp filesystem with no app launch. -@Suite struct AgentChatTranscriptResolverTests { - /// Creates a temp home with a claude project dir for `cwd`, writes the - /// given session-id `.jsonl` files in ascending mtime order, and returns - /// the resolver bound to that home plus the cwd used. - private static func fixture( - sessionsOldestFirst: [String], - cwdName: String = "proj" - ) throws -> (resolver: AgentChatTranscriptResolver, home: URL, cwd: String) { - let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-\(UUID().uuidString)", isDirectory: true) - let cwd = home.appendingPathComponent(cwdName, isDirectory: true) - try fm.createDirectory(at: cwd, withIntermediateDirectories: true) - let projectDir = home - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent( - RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd.path), - isDirectory: true - ) - try fm.createDirectory(at: projectDir, withIntermediateDirectories: true) - // Stamp ascending modification dates so "newest" is deterministic - // without relying on write-order timing. - for (index, sessionID) in sessionsOldestFirst.enumerated() { - let file = projectDir.appendingPathComponent("\(sessionID).jsonl") - try Data("{}\n".utf8).write(to: file) - try fm.setAttributes( - [.modificationDate: Date(timeIntervalSince1970: 1_000_000 + Double(index))], - ofItemAtPath: file.path - ) - } - return (AgentChatTranscriptResolver(homeDirectory: home), home, cwd.path) - } - - @Test("returns the newest transcript when nothing is claimed") - func newestUnclaimed() throws { - let (resolver, _, cwd) = try Self.fixture(sessionsOldestFirst: ["older", "newer"]) - let result = resolver.newestClaudeTranscript(workingDirectory: cwd) - #expect(result?.sessionID == "newer") - } - - @Test("skips a claimed session so a same-dir second agent gets a distinct transcript") - func excludesClaimedSession() throws { - let (resolver, _, cwd) = try Self.fixture(sessionsOldestFirst: ["older", "newer"]) - // The first surface already adopted "newer"; the second must resolve - // to "older" rather than colliding on the same file (or getting nil). - let result = resolver.newestClaudeTranscript( - workingDirectory: cwd, - excludingSessionIDs: ["newer"] - ) - #expect(result?.sessionID == "older") - } - - @Test("returns nil when every transcript is already claimed") - func allClaimedYieldsNil() throws { - let (resolver, _, cwd) = try Self.fixture(sessionsOldestFirst: ["a", "b"]) - let result = resolver.newestClaudeTranscript( - workingDirectory: cwd, - excludingSessionIDs: ["a", "b"] - ) - #expect(result == nil) - } - - @Test("refuses to adopt from the home directory junk drawer") - func homeDirectoryIsGuarded() throws { - // A claude rooted directly at $HOME would match the home project dir, - // which accumulates every home-rooted conversation; newest-by-mtime is - // almost never this terminal's session, so the resolver returns nil. - let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-home-\(UUID().uuidString)", isDirectory: true) - let projectDir = home - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent( - RestorableAgentSessionIndex.encodeClaudeProjectDir(home.path), - isDirectory: true - ) - try fm.createDirectory(at: projectDir, withIntermediateDirectories: true) - try Data("{}\n".utf8).write(to: projectDir.appendingPathComponent("home-sess.jsonl")) - - let resolver = AgentChatTranscriptResolver(homeDirectory: home) - #expect(resolver.newestClaudeTranscript(workingDirectory: home.path) == nil) - } - - @Test("/private-toggled cwd resolves a /private-encoded project dir") - func privatePrefixToggle() throws { - // Simulate claude encoding the /private form while the panel cwd is the - // bare form: create the project dir under the /private-prefixed path and - // resolve from the non-prefixed one. - let fm = FileManager.default - let home = fm.temporaryDirectory - .appendingPathComponent("agentchat-resolver-priv-\(UUID().uuidString)", isDirectory: true) - let bareCwd = "/tmp/agentchat-resolver-\(UUID().uuidString)" - let projectDir = home - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("projects", isDirectory: true) - .appendingPathComponent( - RestorableAgentSessionIndex.encodeClaudeProjectDir("/private" + bareCwd), - isDirectory: true - ) - try fm.createDirectory(at: projectDir, withIntermediateDirectories: true) - try Data("{}\n".utf8).write(to: projectDir.appendingPathComponent("priv-sess.jsonl")) - - let resolver = AgentChatTranscriptResolver(homeDirectory: home) - #expect(resolver.newestClaudeTranscript(workingDirectory: bareCwd)?.sessionID == "priv-sess") - } -} diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index 0ee4904eb4a9..a3c24c27ed4d 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -335,11 +335,15 @@ reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff - **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor` (interim, before it is deleted) and assert no `Data(contentsOf:)` / `JSONSerialization` / `Codable` decode on the main actor in this subsystem. -- **Slice D — Delete the heuristics.** Remove +- **Slice D — Delete the unreliable heuristics. IN PROGRESS.** Remove `AgentChatTranscriptService+TitleDetection.swift`, `newestClaudeTranscript` + - claim/forced-retry machinery, the hook-store as read-side source of truth, and - the 30s throttle. Gated on Slices A-B proving the resolved-hook + pull path is - sufficient. + the claim/forced-retry machinery, provisional surface-keyed sessions, and the + title-key helpers. REVISED SCOPE: the hook-store stays — it is cmux-written, + deterministic binding data (the registry's persistence/seed layer), not an + unreliable guess like titles/mtime; its main-actor read is a threading concern + (Slice C: move off-main), not a deletion. The `kill(pid,0)` sweep was already + removed in Slice B. Transcript resolution by recorded path / exact session id + stays (reliable). Gated for MERGE on the A-B dogfood; implemented on-branch. - **Slice E — Surface-id invariance (only if needed).** Close the collision regeneration (restore-into-live / duplicate-workspace) so `CMUX_SURFACE_ID` is invariant; extend verbatim-rehydrate to non-terminal panels if the GUI uses an From 324f0851316476720e4dda45915a776b83d3891f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:34:16 -0700 Subject: [PATCH 06/38] agent-session SoT spec: Slice D done; C/E/F scoped as follow-ups with rationale Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-session-tracking-spec.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index a3c24c27ed4d..3fc9ebb1b66d 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -332,6 +332,23 @@ reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff `ended` stays retained (disables the input bar, stays visible). `DispatchSource` is an event source, not a timer, so it does not fall under the asyncAfter ban and is cancellable. +- **Status note (after A/B/D):** Slices A, B, D are implemented, build-green on + all layers, and committed on `feat-agent-session-sot`. They deliver the owner's + actual asks: reliable tracking with no title/mtime heuristic, deterministic + `ended` that disables the input bar, and authoritative pull. Slices C, E, F + below are scoped follow-ups, not blockers for the core feature: + - **C (off-main hook-store read):** marginal. The 30s per-session throttle + already bounds the main-actor JSON read to a negligible cost; making + `noteHookEvent`'s backfill async changes a hot-path return contract, so it + belongs with a broader authority cleanup, not this feature. + - **E (surface-id invariance):** conditional, only if a concrete + relaunch-rebinding bug appears (terminal surface id already rehydrates + verbatim on normal relaunch). + - **F (codex hook auto-setup):** real but separate. Codex hooks install via + `cmux hooks setup --agent codex` (into `~/.codex/hooks.json`) gated by + codex's config-TOML trust machinery; auto-installing at launch is a risky + CLI change that deserves its own PR. Until then, codex tracking requires + `cmux hooks setup` to have run (the normal onboarding step). - **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor` (interim, before it is deleted) and assert no `Data(contentsOf:)` / `JSONSerialization` / `Codable` decode on the main actor in this subsystem. From 6f2e0d833de1cfbf2f3ef362d632b84a2788ccc0 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:46:00 -0700 Subject: [PATCH 07/38] agent-session SoT: move hook-store JSON reads off the main actor (Slice C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the owner's directive "no jsonl parsing or heavy work on the main thread." After Slice D the only heavy main-actor parse left in this subsystem was the hook-store whole-file JSON read. Move all three read sites off-main: - seedFromHookStores is now async; the Data(contentsOf:)+JSONSerialization runs in a utility Task.detached, only the (cheap) record application touches main state. start() kicks it off and returns. - noteHookEvent no longer reads the store inline. When a binding is still missing (throttled to once per 30s/session) it returns immediately and defers an off-main backfill (backfillBindingsFromStore) that applies only still-nil fields via update() — so the live event stays authoritative and the hot tool- storm path never parses JSON on main. applyStoreBackfill no-ops when it learns nothing new, avoiding a spurious version bump / descriptor push. - refreshBindingsFromHookStore is async (off-main read); the send/interrupt/ answer + history RPC chain is threaded async to match. The transcript tailer already parses off its own actor; descriptor wire-encoding on main is small, not a whole-file parse. macOS app builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 89 ++++++++++++++----- .../AgentChatTranscriptService.swift | 9 +- Sources/TerminalController+MobileChat.swift | 30 +++---- 3 files changed, 87 insertions(+), 41 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index ba8eb9bb749a..3f1f322182c4 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -144,11 +144,15 @@ final class AgentChatSessionRegistry { /// - Parameter sessionID: The session to refresh. /// - Returns: The refreshed record, or `nil` when unknown. @discardableResult - func refreshBindingsFromHookStore(sessionID: String) -> AgentChatSessionRecord? { + func refreshBindingsFromHookStore(sessionID: String) async -> AgentChatSessionRecord? { guard let record = records[sessionID] else { return nil } - guard let entry = hookStore.entry(agentSource: record.agentKind.sourceName, sessionID: sessionID) else { - return record - } + let store = hookStore + let source = record.agentKind.sourceName + // Whole-file JSON read+parse off the main actor. + let entry = await Task.detached(priority: .utility) { + store.entry(agentSource: source, sessionID: sessionID) + }.value + guard let entry else { return records[sessionID] } update(sessionID: sessionID) { $0.adoptBindings(from: entry, includingPID: false) } return records[sessionID] } @@ -189,14 +193,20 @@ final class AgentChatSessionRegistry { } /// Seeds the registry from the on-disk hook stores so sessions started - /// before app launch are listable immediately. Dead processes register - /// as ended. + /// before app launch are listable. The whole-file JSON read+parse runs off + /// the main actor; only the (cheap) record application touches main state. + /// Dead processes register as ended. /// /// - Parameter agentSources: The agent store files to read. - func seedFromHookStores(agentSources: [String] = ["claude", "codex"]) { - for source in agentSources { + func seedFromHookStores(agentSources: [String] = ["claude", "codex"]) async { + let store = hookStore + let parsed: [(source: String, entries: [AgentChatHookSessionStore.Entry])] = + await Task.detached(priority: .utility) { + agentSources.map { (source: $0, entries: store.entries(agentSource: $0)) } + }.value + for (source, entries) in parsed { let kind = ChatAgentKind(source: source) - for entry in hookStore.entries(agentSource: source) { + for entry in entries { guard records[entry.sessionID] == nil else { continue } let alive = entry.pid.map { kill(pid_t($0), 0) == 0 } ?? false var record = AgentChatSessionRecord( @@ -251,23 +261,19 @@ final class AgentChatSessionRegistry { record.pid = event.ppid hookStoreConsultedAt[sessionID] = event.receivedAt } - // The hook store is a whole-file JSON read on the main actor; - // consult it at most every 30s per session while fields are still - // missing (pid can legitimately stay absent), not on every - // pre/postToolUse during a tool storm. Consult BEFORE applying the - // event's own fields: the store lags the event by one write, so - // the live event must win any disagreement. + // The hook store is a whole-file JSON read+parse; never do it on the + // main actor. Consult it at most every 30s per session while bindings + // are still missing (pid can legitimately stay absent), not on every + // pre/postToolUse during a tool storm. The read is deferred off-main + // (see backfillBindingsFromStore) and applied later, filling only + // still-nil fields — so the live event below always wins a disagreement + // (the store lags the event by one write). let needsHookStore = record.surfaceID == nil || record.transcriptPath == nil || record.pid == nil let lastConsult = hookStoreConsultedAt[sessionID] - if needsHookStore, - lastConsult.map({ event.receivedAt.timeIntervalSince($0) > 30 }) ?? true { + let shouldConsultStore = needsHookStore + && (lastConsult.map { event.receivedAt.timeIntervalSince($0) > 30 } ?? true) + if shouldConsultStore { hookStoreConsultedAt[sessionID] = event.receivedAt - if let entry = hookStore.entry(agentSource: event.source, sessionID: sessionID) { - // Adopt the store pid only when the record has none: the - // record's pid comes from the event's own ppid and is - // fresher than a store entry that may predate a resume. - record.adoptBindings(from: entry, includingPID: record.pid == nil) - } } if let workspaceID = event.workspaceId, !workspaceID.isEmpty { record.workspaceID = workspaceID @@ -284,9 +290,46 @@ final class AgentChatSessionRegistry { syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) + if shouldConsultStore { + backfillBindingsFromStore(sessionID: sessionID, agentSource: event.source) + } return record } + /// Reads one session's hook-store entry OFF the main actor and applies any + /// still-missing bindings on the main actor. The hot path (`noteHookEvent`) + /// returns immediately; bindings land a moment later via `update`, which + /// re-tails and pushes if the transcript path just became known. Filling + /// only nil fields keeps the live event authoritative over the lagging + /// store. + private func backfillBindingsFromStore(sessionID: String, agentSource: String) { + let store = hookStore + Task { [weak self] in + let entry = await Task.detached(priority: .utility) { + store.entry(agentSource: agentSource, sessionID: sessionID) + }.value + guard let self, let entry else { return } + self.applyStoreBackfill(sessionID: sessionID, entry: entry) + } + } + + /// Applies a hook-store entry's non-nil bindings to a record, but only when + /// it actually changes something — so a backfill that learns nothing new + /// does not bump the version or emit a no-op descriptor push. + private func applyStoreBackfill(sessionID: String, entry: AgentChatHookSessionStore.Entry) { + guard let current = records[sessionID] else { return } + var candidate = current + candidate.adoptBindings(from: entry, includingPID: current.pid == nil) + guard candidate.surfaceID != current.surfaceID + || candidate.workspaceID != current.workspaceID + || candidate.transcriptPath != current.transcriptPath + || candidate.workingDirectory != current.workingDirectory + || candidate.pid != current.pid else { return } + update(sessionID: sessionID) { record in + record.adoptBindings(from: entry, includingPID: record.pid == nil) + } + } + private func updateLiveSessionIndex( previous: AgentChatSessionRecord?, current: AgentChatSessionRecord diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 52e5466c246a..aa0b4a12ce44 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -46,7 +46,10 @@ final class AgentChatTranscriptService { /// at app startup. Sessions are tracked only via the reliable hook-event /// path thereafter; cmux does not detect agents that never fire a hook. func start() { - registry.seedFromHookStores() + // Seeding reads+parses the hook-store JSON off the main actor; kick it + // off and return. Live hook events also populate the registry, and the + // seed converges within milliseconds. + Task { [weak self] in await self?.registry.seedFromHookStores() } } /// Ingests one hook event (called from the socket dispatch path). @@ -100,8 +103,8 @@ final class AgentChatTranscriptService { /// Re-adopts one session's terminal bindings from the hook store; see /// ``AgentChatSessionRegistry/refreshBindingsFromHookStore(sessionID:)``. @discardableResult - func refreshSessionBindings(sessionID: String) -> AgentChatSessionRecord? { - registry.refreshBindingsFromHookStore(sessionID: sessionID) + func refreshSessionBindings(sessionID: String) async -> AgentChatSessionRecord? { + await registry.refreshBindingsFromHookStore(sessionID: sessionID) } /// Serves one history page, starting the session's tailer on demand. diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 28ce7ebf49bf..ad59c970aeb0 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -38,11 +38,11 @@ extension TerminalController { case "mobile.chat.history": return await v2MobileChatHistory(params: params) case "mobile.chat.send": - return v2MobileChatSend(params: params) + return await v2MobileChatSend(params: params) case "mobile.chat.interrupt": - return v2MobileChatInterrupt(params: params) + return await v2MobileChatInterrupt(params: params) case "mobile.chat.answer": - return v2MobileChatAnswer(params: params) + return await v2MobileChatAnswer(params: params) default: return .err(code: "method_not_found", message: "Unknown mobile method", data: [ "method": method @@ -122,7 +122,7 @@ extension TerminalController { #if DEBUG cmuxDebugLog("mobile.chat.history transcript unresolved session=\(sessionID.prefix(8)); refreshing bindings") #endif - let refreshed = service.refreshSessionBindings(sessionID: sessionID) + let refreshed = await service.refreshSessionBindings(sessionID: sessionID) if refreshed?.transcriptPath != staleRecord.transcriptPath || refreshed?.workingDirectory != staleRecord.workingDirectory { page = await service.history(sessionID: sessionID, beforeSeq: beforeSeq, limit: limit) @@ -147,7 +147,7 @@ extension TerminalController { /// `mobile.chat.send`: deliver attachments then inject the prompt into /// the session's terminal (bracketed paste + submit key). - func v2MobileChatSend(params: [String: Any]) -> V2CallResult { + func v2MobileChatSend(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id") else { return .err(code: "invalid_params", message: "Missing session_id", data: nil) } @@ -156,12 +156,12 @@ extension TerminalController { guard !text.isEmpty || !attachments.isEmpty else { return .err(code: "invalid_params", message: "Nothing to send", data: nil) } - guard let terminalParams = mobileChatTerminalParams(sessionID: sessionID) else { + guard let terminalParams = await mobileChatTerminalParams(sessionID: sessionID) else { return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) } - guard let terminalPanel = mobileChatTerminalPanel(sessionID: sessionID) else { + guard let terminalPanel = await mobileChatTerminalPanel(sessionID: sessionID) else { return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) @@ -227,12 +227,12 @@ extension TerminalController { /// `mobile.chat.interrupt`: polite (Esc) or hard (ctrl-C) interrupt of /// the session's agent. - func v2MobileChatInterrupt(params: [String: Any]) -> V2CallResult { + func v2MobileChatInterrupt(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id") else { return .err(code: "invalid_params", message: "Missing session_id", data: nil) } let hard = (params["hard"] as? Bool) ?? false - guard let terminalPanel = mobileChatTerminalPanel(sessionID: sessionID) else { + guard let terminalPanel = await mobileChatTerminalPanel(sessionID: sessionID) else { return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) @@ -250,12 +250,12 @@ extension TerminalController { /// `mobile.chat.answer`: answer an in-terminal choice by display index /// (agent TUIs accept the option's number key). - func v2MobileChatAnswer(params: [String: Any]) -> V2CallResult { + func v2MobileChatAnswer(params: [String: Any]) async -> V2CallResult { guard let sessionID = v2RawString(params, "session_id"), let optionIndex = v2Int(params, "option_index"), optionIndex >= 0, optionIndex < 9 else { return .err(code: "invalid_params", message: "Missing session_id or option_index", data: nil) } - guard let terminalPanel = mobileChatTerminalPanel(sessionID: sessionID) else { + guard let terminalPanel = await mobileChatTerminalPanel(sessionID: sessionID) else { return .err(code: "not_found", message: Self.chatTerminalBindingErrorMessage, data: [ "session_id": sessionID ]) @@ -283,7 +283,7 @@ extension TerminalController { /// hook store (every hook event rewrites it with the current panel) and /// retried. If it still doesn't resolve we fail with an actionable error /// rather than redirect the prompt to some other terminal. - private func mobileChatTerminalParams(sessionID: String) -> [String: Any]? { + private func mobileChatTerminalParams(sessionID: String) async -> [String: Any]? { guard let service = agentChatTranscriptService else { return nil } guard let record = service.sessionRecord(sessionID: sessionID), let workspaceID = record.workspaceID else { @@ -297,7 +297,7 @@ extension TerminalController { #if DEBUG cmuxDebugLog("mobile.chat binding stale session=\(sessionID.prefix(8)) surface=\(record.surfaceID?.prefix(8) ?? "nil"); refreshing from hook store") #endif - if let refreshed = service.refreshSessionBindings(sessionID: sessionID), + if let refreshed = await service.refreshSessionBindings(sessionID: sessionID), let surfaceID = refreshed.surfaceID, mobileChatBindingResolves(workspaceID: workspaceID, surfaceID: surfaceID), mobileChatBindingIsCurrentAgent(refreshed) { @@ -354,8 +354,8 @@ extension TerminalController { } } - private func mobileChatTerminalPanel(sessionID: String) -> TerminalPanel? { - guard let terminalParams = mobileChatTerminalParams(sessionID: sessionID), + private func mobileChatTerminalPanel(sessionID: String) async -> TerminalPanel? { + guard let terminalParams = await mobileChatTerminalParams(sessionID: sessionID), let resolved = mobileResolveWorkspaceAndSurface(params: terminalParams, requireTerminal: true), let surfaceId = resolved.surfaceId else { #if DEBUG From ef03f5c4a1cc065ba1df2d5b0a52a208fc34634b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:47:35 -0700 Subject: [PATCH 08/38] agent-session SoT spec: Slice C done; E/F deferred with rationale Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-session-tracking-spec.md | 32 ++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index 3fc9ebb1b66d..5450599e33bc 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -337,18 +337,26 @@ reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff actual asks: reliable tracking with no title/mtime heuristic, deterministic `ended` that disables the input bar, and authoritative pull. Slices C, E, F below are scoped follow-ups, not blockers for the core feature: - - **C (off-main hook-store read):** marginal. The 30s per-session throttle - already bounds the main-actor JSON read to a negligible cost; making - `noteHookEvent`'s backfill async changes a hot-path return contract, so it - belongs with a broader authority cleanup, not this feature. - - **E (surface-id invariance):** conditional, only if a concrete - relaunch-rebinding bug appears (terminal surface id already rehydrates - verbatim on normal relaunch). - - **F (codex hook auto-setup):** real but separate. Codex hooks install via - `cmux hooks setup --agent codex` (into `~/.codex/hooks.json`) gated by - codex's config-TOML trust machinery; auto-installing at launch is a risky - CLI change that deserves its own PR. Until then, codex tracking requires - `cmux hooks setup` to have run (the normal onboarding step). + - **C (off-main hook-store read): DONE.** All three hook-store read sites + (`seedFromHookStores`, `noteHookEvent`'s throttled consult via a deferred + `backfillBindingsFromStore`, `refreshBindingsFromHookStore`) now read+parse + the JSON off the main actor via `Task.detached`; the send/interrupt/answer + RPC chain was threaded async to match. Satisfies the owner's "no jsonl + parsing / heavy work on the main thread" directive. macOS app builds. + - **E (surface-id invariance): deferred, by spec.** Zero benefit to this + feature: agents run in TERMINAL surfaces, whose ids already rehydrate + verbatim on normal relaunch. Only non-terminal panel ids regenerate, which + does not affect agent tracking. The spec marks E conditional ("only if a + concrete relaunch-rebinding bug appears"), so deferring it is following the + spec; implementing it speculatively risks the restore subsystem for no gain. + - **F (codex hook auto-setup): deferred, needs a product decision.** The only + way to "guarantee" codex hooks is to silently edit the user's + `~/.codex/config.toml` trust entries + `hooks.json` at launch — a standing + modification to external tooling config that should not be done silently and + warrants its own PR. Codex is tracked today for anyone who ran + `cmux hooks setup` (the existing onboarding step). Safe future options: an + explicit "install codex hooks" affordance, or a one-line launch hint when a + codex agent starts without hooks installed. - **Slice C — Off-main parsing.** Move the hook-store JSON read off `@MainActor` (interim, before it is deleted) and assert no `Data(contentsOf:)` / `JSONSerialization` / `Codable` decode on the main actor in this subsystem. From e6f480463308050f71681c936d3dda7655de15d3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 19:52:10 -0700 Subject: [PATCH 09/38] agent-session SoT spec: resolve Slice E (not needed; invariant already holds for terminal agents) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/agent-session-tracking-spec.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/agent-session-tracking-spec.md b/docs/agent-session-tracking-spec.md index 5450599e33bc..d5fa572e409d 100644 --- a/docs/agent-session-tracking-spec.md +++ b/docs/agent-session-tracking-spec.md @@ -343,12 +343,15 @@ reviewable slices. Each runtime slice ends in a tagged build + dogfood handoff the JSON off the main actor via `Task.detached`; the send/interrupt/answer RPC chain was threaded async to match. Satisfies the owner's "no jsonl parsing / heavy work on the main thread" directive. macOS app builds. - - **E (surface-id invariance): deferred, by spec.** Zero benefit to this - feature: agents run in TERMINAL surfaces, whose ids already rehydrate - verbatim on normal relaunch. Only non-terminal panel ids regenerate, which - does not affect agent tracking. The spec marks E conditional ("only if a - concrete relaunch-rebinding bug appears"), so deferring it is following the - spec; implementing it speculatively risks the restore subsystem for no gain. + - **E (surface-id invariance): RESOLVED — not needed.** E's trigger condition + ("a concrete relaunch-rebinding bug") does not exist for this feature. + Agents run in TERMINAL surfaces, and the Phase-1 audit confirmed terminal + surface ids rehydrate verbatim on normal relaunch (`Workspace.swift:1340`); + only non-terminal panel ids regenerate, which never carries an agent + session. So the durable-key invariant the design needs already holds for the + agent case. E stays unimplemented intentionally; implementing it would change + the restore subsystem for zero benefit and real regression risk. Re-open + only if a real relaunch-rebinding bug is observed. - **F (codex hook auto-setup): deferred, needs a product decision.** The only way to "guarantee" codex hooks is to silently edit the user's `~/.codex/config.toml` trust entries + `hooks.json` at launch — a standing From 5d0d79ee3de94a1c0c6f061559325c9c7f642526 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 21:01:18 -0700 Subject: [PATCH 10/38] =?UTF-8?q?codex=20detection:=20plan=20(Slice=20F)?= =?UTF-8?q?=20=E2=80=94=20wrapper-emits-session-start,=20no=20global=20ins?= =?UTF-8?q?tall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/codex-agent-detection-plan.md | 135 +++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/codex-agent-detection-plan.md diff --git a/docs/codex-agent-detection-plan.md b/docs/codex-agent-detection-plan.md new file mode 100644 index 000000000000..c54c57fba66e --- /dev/null +++ b/docs/codex-agent-detection-plan.md @@ -0,0 +1,135 @@ +# Codex Agent Detection Plan + +Status: DRAFT. Owner: Aziz. Last updated: 2026-06-22. + +Expands Slice F of `agent-session-tracking-spec.md`: make Codex sessions track in +the iOS GUI as reliably as Claude, without forcing users to install anything and +without silently editing their `~/.codex` config. + +## Problem + +Codex agents are not reliably detected in the GUI. Empirical root cause on a real +machine (Aziz's, 2026-06-22): + +- cmux's Codex hooks (`~/.codex/hooks.json`) are NOT installed. +- Codex has a single legacy `notify` slot, and it is taken by Computer Use: + `notify = [".../SkyComputerUseClient", "turn-ended"]`. cmux's notify-based + events cannot fire. +- Slice D removed the terminal-title / newest-jsonl-by-mtime fallback + (intentionally — no unreliable fallback), so a hook-less Codex is invisible. +- The 13 "codex" entries in `~/.cmuxterm/codex-hook-sessions.json` are stale, + not live-updating. + +Why Claude works, for contrast: Claude Code has a `SessionStart` hook. cmux's +`cmux-claude-wrapper` is a PATH shim that injects cmux's hooks per-invocation +(`--settings`) and execs the real claude, so `SessionStart` fires the instant a +claude session starts. Transparent, per-launch, nothing written to `~/.claude`, +works for hand-typed `claude`. + +## Principle + +cmux owns the terminal environment, so it can mediate any agent the user launches +in its terminal, per-invocation, without touching global config. Detection must +depend only on what cmux controls — the wrapper, the injected env, the pid it +parents — never on the agent cooperating. This is the same primitive that makes +Claude reliable, generalized to Codex. + +## Design: PATH-shim wrapper that emits its own session-start + +A `cmux-codex-wrapper`, mirroring `cmux-claude-wrapper`: + +1. cmux prepends a shim dir to `PATH` when it spawns its terminal shells (the + same mechanism Claude already uses). Typing `codex` resolves to the shim, not + the real binary. +2. Before exec'ing the real codex, the wrapper emits the launch signal itself: + `cmux hooks codex session-start` carrying `CMUX_SURFACE_ID`, the cwd, and its + child pid. THIS is the reliable detection signal, and it needs nothing from + Codex — the wrapper, which cmux controls, is the source. (More robust than the + Claude path, where the signal comes from Claude's own hook.) +3. The wrapper execs the real codex (resolved by skipping the shim on `PATH`). +4. Outside cmux (no `CMUX_SURFACE_ID` / socket), the wrapper no-ops and execs the + real codex, so it is invisible to non-cmux usage. + +Session lifecycle, with NO Codex hooks required: + +- Presence + terminal binding: from the wrapper's session-start (surface + pid, + deterministic). +- Transcript: resolve the Codex rollout JSONL the pid is writing, anchored to the + pid (the process's open file descriptors, or a launch-time-bounded match + confirmed against the pid). This is an identity, NOT the deleted "newest jsonl + by mtime in the cwd" guess. Then tail it. +- State (working / idle): derived from the transcript tail. `CodexTranscriptParser` + already exists. +- ended: the Slice-B `DispatchSourceProcess(.exit)` watcher on the pid. +- Codex's own hooks: optional enhancement for finer / faster state, never a + requirement. + +## The `notify` coexistence wrinkle + +This is the only Codex-specific complication, and it is bounded, not fundamental. +Codex has one legacy `notify` slot, frequently already taken (Computer Use). If we +want Codex's own notify events too, do NOT clobber it. Options, simplest first: + +- Skip `notify` entirely and rely on transcript-derived state. If the rollout + covers the states we need, the wrapper's session-start + transcript tail + + process-exit are sufficient and `notify` is unnecessary. +- Chain: read the user's existing `notify` from `config.toml`; cmux's injected + notify handler forwards to the original after handling. Decorator pattern, + preserves Computer Use. +- Use Codex's newer multi-hook system if the current CLI accepts a per-invocation + config override that adds a hook alongside existing ones (no chaining needed). + +## Reliability assessment (honest) + +Super reliable: +- Detection of any Codex that cmux launches OR the user types in a cmux terminal. + Anchored to the wrapper-emitted session-start (surface + pid cmux owns). +- `ended`, via the process-exit watcher. + +Bounded edges, acceptable under the no-unreliable-fallback stance (Claude has the +identical limits): +- Deliberate bypass is not caught: absolute path (`/usr/local/bin/codex`), an + alias/function that skips the shim, `env -i`, a PATH reset, or Codex over ssh on + a host cmux does not own. Not surfacing a truly-unmediated agent is correct + behavior, not a bug. +- Fine-grained `needsInput` (Codex paused on an approval/answer) is + transcript-paced without Codex's own hooks. Reliable IF the rollout records + approval/needs-input events; coarse if it does not. + +## Open questions to verify before building + +1. Does a `cmux-claude-wrapper`-style PATH shim already exist, and where is the + shim dir injected into terminal `PATH`? (Reuse the same machinery for codex.) +2. Does the Codex rollout JSONL expose approval / needs-input events, for reliable + `needsInput` without Codex hooks? +3. The current Codex CLI per-invocation config surface (`-c` overrides; multi-hook + support), to decide notify-chaining vs. skip vs. multi-hook. +4. Does cmux's existing Codex launch path (`codex-teams` / + `upsertCodexSessionStartIfFresh`) already register into the iOS chat registry + (`AgentChatSessionRegistry`), or only write the stale hook store? Determines + how much is wiring vs. new. + +## Implementation steps + +1. Verify the four open questions above. +2. Add `cmux-codex-wrapper` to the same shim dir + PATH injection Claude uses. +3. Wrapper emits `cmux hooks codex session-start` (surface / pid / cwd) before + exec, and no-ops cleanly outside cmux. +4. Wire the Codex session-start into `AgentChatSessionRegistry` (the iOS chat + registry), if it does not already land there. +5. pid-anchored Codex transcript resolution (open-fd / launch-bounded), replacing + any reliance on session-id-from-hook for the typed case. +6. Confirm `CodexTranscriptParser` yields working / idle (/ needsInput) from the + rollout; fill gaps. +7. `notify` coexistence: skip, chain, or multi-hook per Q3. +8. Build + dogfood: type `codex` in a cmux terminal, confirm it appears in the + GUI, state tracks, and it ends on exit; confirm Computer Use's `notify` still + fires. + +## Relationship to the main spec + +This is `agent-session-tracking-spec.md` Slice F, expanded. It replaces that +slice's "deferred — needs a product decision" note: the wrapper approach needs no +global install and edits no user config, so there is no product decision to gate +on. Detection does not depend on Codex's hook support, which is what makes it +principled rather than a per-agent hack. From 4bcca3eb69f00f708c85d547b4367cd6699aa378 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 21:28:23 -0700 Subject: [PATCH 11/38] codex detection: cmux-codex-wrapper + PATH shim + per-invocation hook injection (Slice F) Make Codex sessions track in the iOS GUI as reliably as Claude, without installing anything into the user's ~/.codex and without clobbering their existing notify/config. cmux-codex-wrapper mirrors cmux-claude-wrapper: when inside a cmux terminal (CMUX_SURFACE_ID + live socket) and a session entrypoint (bare codex, a prompt, or codex exec/e), it execs the real codex with per-invocation hooks: --enable hooks --dangerously-bypass-hook-trust -c 'hooks.SessionStart=[{hooks=[{type="command",command='''''',timeout=...}]}]' (and UserPromptSubmit/Stop/PreToolUse/PostToolUse/PermissionRequest) The injected command is the exact gated shape cmux installs for persisted codex hooks (resolve cmux CLI, require surface+socket+not-disabled, run 'cmux hooks codex ', else echo '{}'), carried as a TOML multi-line literal string so its single quotes need no escaping. Verified empirically against codex-cli 0.141.0: all hooks fire and codex passes session_id + transcript_path on stdin, binding the transcript by real session id. Belt-and-suspenders: the wrapper also fires a one-way 'cmux hooks codex session-start' (surface/pid/cwd, empty stdin) BEFORE exec, so detection happens at launch even if codex's own SessionStart is delayed; the registry dedups by session id so the two reconcile. Passthrough safety mirrors the claude wrapper exactly: every gate (opt-out via CMUX_CODEX_HOOKS_DISABLED, outside cmux, dead socket, non-session subcommand like resume/doctor/--help) and find_real_codex failure exec the real codex unchanged, so installing the wrapper can never break codex. A per-surface 'codex' PATH shim is written into the same cmux-cli-shims dir as the claude shim (already on PATH), resolving+exec'ing the wrapper, else stripping the shim dirs and exec'ing real codex. Bundled into the app Resources/bin via the Copy CLI phase alongside cmux-claude-wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../TerminalSurface+StartupEnvironment.swift | 85 +++++ .../Surface/TerminalSurface.swift | 1 + .../TerminalSurfaceCodexCommandShim.swift | 23 ++ Resources/bin/cmux-codex-wrapper | 323 ++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + 5 files changed, 436 insertions(+) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift create mode 100755 Resources/bin/cmux-codex-wrapper diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift index 7be3730d5126..30c637c79fc5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift @@ -160,6 +160,16 @@ extension TerminalSurface { """ try script.write(to: shimURL, atomically: true, encoding: .utf8) try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shimURL.path) + // Best-effort: write a sibling `codex` shim into the same per-surface + // dir so typed `codex` resolves to cmux-codex-wrapper through the + // PATH entry already prepended for the claude shim. Failure here + // never blocks the claude shim (codex detection degrades, claude is + // unaffected). + installCodexCommandShimIfPossible( + claudeWrapperURL: wrapperURL, + shimDirectory: shimDirectory, + fileManager: fileManager + ) return ClaudeCommandShim( directoryPath: shimDirectory.path, executablePath: shimURL.path @@ -169,6 +179,81 @@ extension TerminalSurface { } } + /// Writes the per-surface `codex` wrapper shim into `shimDirectory`, if the + /// bundled `cmux-codex-wrapper` exists alongside `cmux-claude-wrapper`. The + /// shim resolves and execs the codex wrapper; if the wrapper is gone it + /// strips every cmux shim dir from `PATH` and execs the real `codex`, so the + /// user's `codex` keeps working even when the app bundle is pruned. + /// + /// The directory is already prepended to the spawned shell's `PATH` for the + /// claude shim, so no extra `PATH` handling is required. + @discardableResult + public static func installCodexCommandShimIfPossible( + claudeWrapperURL: URL, + shimDirectory: URL, + fileManager: FileManager = .default + ) -> CodexCommandShim? { + let codexWrapperURL = claudeWrapperURL + .deletingLastPathComponent() + .appendingPathComponent("cmux-codex-wrapper", isDirectory: false) + .standardizedFileURL + guard fileManager.isExecutableFile(atPath: codexWrapperURL.path) else { + return nil + } + + let shimURL = shimDirectory.appendingPathComponent("codex", isDirectory: false) + do { + let script = """ + #!/usr/bin/env bash + cmux_wrapper=\(shellSingleQuoted(codexWrapperURL.path)) + if [[ ! -x "$cmux_wrapper" && -n "${CMUX_BUNDLED_CLI_PATH:-}" ]]; then + cmux_candidate="$(dirname "$CMUX_BUNDLED_CLI_PATH")/cmux-codex-wrapper" + if [[ -x "$cmux_candidate" ]]; then + cmux_wrapper="$cmux_candidate" + fi + fi + if [[ ! -x "$cmux_wrapper" ]]; then + cmux_cli="$(command -v cmux 2>/dev/null || true)" + if [[ -n "$cmux_cli" ]]; then + cmux_candidate="$(dirname "$cmux_cli")/cmux-codex-wrapper" + if [[ -x "$cmux_candidate" ]]; then + cmux_wrapper="$cmux_candidate" + fi + fi + fi + export CMUX_CODEX_WRAPPER_SHIM=\(shellSingleQuoted(shimURL.path)) + export CMUX_CODEX_WRAPPER_SHIM_ROOT=\(shellSingleQuoted(shimDirectory.path)) + if [[ -x "$cmux_wrapper" ]]; then + exec "$cmux_wrapper" "$@" + fi + cmux_path_without_shim="" + cmux_old_ifs="$IFS" + IFS=: + for cmux_entry in ${PATH:-}; do + if [[ "$cmux_entry" == "$CMUX_CODEX_WRAPPER_SHIM_ROOT" || "$cmux_entry" == */cmux-cli-shims/* || "$cmux_entry" == */cmux-cli-shims ]]; then + continue + fi + if [[ -z "$cmux_path_without_shim" ]]; then + cmux_path_without_shim="$cmux_entry" + else + cmux_path_without_shim="$cmux_path_without_shim:$cmux_entry" + fi + done + IFS="$cmux_old_ifs" + export PATH="$cmux_path_without_shim" + exec codex "$@" + """ + try script.write(to: shimURL, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: shimURL.path) + return CodexCommandShim( + directoryPath: shimDirectory.path, + executablePath: shimURL.path + ) + } catch { + return nil + } + } + /// Merges base, additional, and override environments with key /// protection, Claude auth-selection inheritance, and config-dir /// normalization. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 9b06b7924e01..f2af096a63d1 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -56,6 +56,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { public typealias NamedKeySendResult = CmuxTerminalCore.NamedKeySendResult public typealias InputSendResult = CmuxTerminalCore.InputSendResult public typealias ClaudeCommandShim = TerminalSurfaceClaudeCommandShim + public typealias CodexCommandShim = TerminalSurfaceCodexCommandShim public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment /// The live runtime surface pointer, or nil before creation/after teardown. public internal(set) var surface: ghostty_surface_t? diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift new file mode 100644 index 000000000000..7134038dd1b5 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceCodexCommandShim.swift @@ -0,0 +1,23 @@ +/// The on-disk `codex` wrapper shim installed for one terminal surface. +/// +/// The shim lives in the same per-surface directory as the `claude` shim, which +/// is already prepended to the spawned shell's `PATH`, so `codex` resolves to +/// the cmux codex wrapper; both paths are exported to the shell as +/// `CMUX_CODEX_WRAPPER_SHIM` / `CMUX_CODEX_WRAPPER_SHIM_ROOT`. +public struct TerminalSurfaceCodexCommandShim: Equatable, Sendable { + /// The per-surface shim directory (shared with the claude shim). + public let directoryPath: String + + /// The executable shim script inside ``directoryPath``. + public let executablePath: String + + /// Creates a shim descriptor. + /// + /// - Parameters: + /// - directoryPath: The per-surface shim directory. + /// - executablePath: The executable shim script inside the directory. + public init(directoryPath: String, executablePath: String) { + self.directoryPath = directoryPath + self.executablePath = executablePath + } +} diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper new file mode 100755 index 000000000000..df04dc4faac7 --- /dev/null +++ b/Resources/bin/cmux-codex-wrapper @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# cmux codex wrapper - per-invocation Codex hook injection + launch detection. +# +# When running inside a cmux terminal (CMUX_SURFACE_ID is set), this wrapper +# makes `codex` carry cmux's hooks for THIS invocation only (nothing is written +# to ~/.codex), so Codex's own SessionStart/UserPromptSubmit/Stop/PreToolUse/ +# PostToolUse/PermissionRequest fire back into cmux with Codex's real session_id. +# It also fires a best-effort one-way `cmux hooks codex session-start` BEFORE +# exec so a session is detected at launch even if Codex's own SessionStart is +# delayed; the registry dedups by session id so the two are idempotent. +# +# Outside cmux (no CMUX_SURFACE_ID / live socket), or when the user opts out +# (CMUX_CODEX_HOOKS_DISABLED=1), the wrapper does NOTHING but exec the real +# codex. Every failure path below also execs the real codex, so installing the +# wrapper can never break `codex`. + +# Deliberately NOT using `set -e`/`set -u`: a wrapper that aborts on any error +# would break the user's `codex`. Each helper handles its own failures and the +# script always reaches an `exec "$REAL_CODEX"`. + +cmux_codex_wrapper_is_self_or_shim() { + local candidate="$1" + [[ -n "$candidate" ]] || return 1 + if [[ -e "$candidate" && "$candidate" -ef "$0" ]]; then + return 0 + fi + if [[ -n "${CMUX_CODEX_WRAPPER_SHIM:-}" && + -e "$candidate" && + -e "$CMUX_CODEX_WRAPPER_SHIM" && + "$candidate" -ef "$CMUX_CODEX_WRAPPER_SHIM" ]]; then + return 0 + fi + if [[ -n "${CMUX_CODEX_WRAPPER_SHIM_ROOT:-}" && + "$candidate" == "${CMUX_CODEX_WRAPPER_SHIM_ROOT%/}/codex" ]]; then + return 0 + fi + case "$candidate" in + */cmux-cli-shims/*/codex|*/cmux-cli-shims/codex) + return 0 + ;; + */Contents/Resources/bin/codex|*/Resources/bin/codex) + return 0 + ;; + esac + return 1 +} + +# Find the real codex binary, skipping this wrapper and cmux's shell shims. +find_real_codex() { + local custom="${CMUX_CUSTOM_CODEX_PATH:-}" + custom="${custom#"${custom%%[![:space:]]*}"}" # trim leading whitespace + custom="${custom%"${custom##*[![:space:]]}"}" # trim trailing whitespace + if [[ -n "$custom" && -f "$custom" && -x "$custom" ]]; then + if ! cmux_codex_wrapper_is_self_or_shim "$custom"; then + printf '%s' "$custom" + return 0 + fi + fi + local self_dir + self_dir="$(cd "$(dirname "$0")" && pwd)" + local IFS=: + for d in $PATH; do + [[ "$d" == "$self_dir" ]] && continue + local candidate="$d/codex" + cmux_codex_wrapper_is_self_or_shim "$candidate" && continue + [[ -x "$candidate" ]] && printf '%s' "$candidate" && return 0 + done + return 1 +} + +# Return 0 only when CMUX_SOCKET_PATH points to a live cmux socket. +cmux_socket_available() { + local socket="${CMUX_SOCKET_PATH:-}" + [[ -n "$socket" && -S "$socket" ]] || return 1 + + local cmux_bin + cmux_bin="$(resolve_hook_cmux_bin)" + [[ -n "$cmux_bin" ]] || return 1 + + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \ + "$cmux_bin" --socket "$socket" ping >/dev/null 2>&1 +} + +resolve_hook_cmux_bin() { + local self_dir bundled_cli + bundled_cli="${CMUX_BUNDLED_CLI_PATH:-}" + if [[ -n "$bundled_cli" && -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + self_dir="$(cd "$(dirname "$0")" && pwd)" + bundled_cli="$self_dir/cmux" + if [[ -x "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + bundled_cli="$(command -v cmux 2>/dev/null || true)" + if [[ -n "$bundled_cli" ]]; then + printf '%s' "$bundled_cli" + return 0 + fi + + printf '%s' "cmux" +} + +# Only the interactive session and `exec`/`e` entrypoints start a Codex session; +# everything else (resume, review, login, mcp, doctor, --help, --version, ...) +# must pass through untouched so it never receives session/hook flags. Mirrors +# should_inject_claude_hooks: a leading non-option token that is a known Codex +# subcommand means "not a session" unless it is the exec alias. +codex_subcommand_starts_session() { + case "$1" in + exec|e) return 0 ;; + *) return 1 ;; + esac +} + +codex_known_subcommand() { + case "$1" in + exec|e|review|login|logout|mcp|plugin|mcp-server|app-server|\ + remote-control|app|completion|update|doctor|sandbox|debug|apply|a|\ + resume|archive|delete|unarchive|fork|cloud|exec-server|features|help) + return 0 + ;; + esac + return 1 +} + +# Options that take a value, so a following non-option token is the value and +# not the subcommand. Conservative: unknown `--flag value` is handled by the +# "first bare token decides" rule below, which is safe because Codex session +# launches take an optional prompt, not a subcommand. +codex_option_consumes_value() { + case "$1" in + -c|--config|-m|--model|-p|--profile|-C|--cd|--remote|-a|--ask-for-approval|\ + -s|--sandbox|--output-last-message|--enable|--disable) + return 0 + ;; + esac + return 1 +} + +codex_passthrough_option_flag() { + case "$1" in + --help|-h|-V|--version) + return 0 + ;; + esac + return 1 +} + +# Decide whether this invocation is a Codex SESSION entrypoint we should inject +# hooks into. Bare `codex` (no args) and `codex [prompt]` are interactive +# sessions; `codex exec ...` is a non-interactive session; any other leading +# subcommand is not. +should_inject_codex_hooks() { + (( $# == 0 )) && return 0 + + local arg + local skip_next=false + for arg in "$@"; do + if [[ "$skip_next" == true ]]; then + skip_next=false + continue + fi + case "$arg" in + --) + return 0 + ;; + -*) + if codex_passthrough_option_flag "$arg"; then + return 1 + fi + if [[ "$arg" != *=* ]] && codex_option_consumes_value "$arg"; then + skip_next=true + fi + continue + ;; + *) + if codex_known_subcommand "$arg"; then + codex_subcommand_starts_session "$arg" && return 0 + return 1 + fi + # First bare token is a prompt -> interactive session. + return 0 + ;; + esac + done + + return 0 +} + +# Whether the invocation uses the non-interactive `exec`/`e` subcommand, which +# requires --skip-git-repo-check tolerance differently; we never add flags codex +# would reject, so this only gates the best-effort wrapper-fired session-start +# transcript hint. (Currently informational; kept for clarity/extension.) + +IN_CMUX=0 +if [[ -n "${CMUX_SURFACE_ID:-}" ]]; then + IN_CMUX=1 +fi + +# Resolve real codex up front; if it cannot be found we error like a missing +# binary would (same as the claude wrapper) rather than silently succeeding. +REAL_CODEX="$(find_real_codex)" || { echo "Error: codex not found in PATH" >&2; exit 127; } + +exec_real_codex_passthrough() { + if [[ "$IN_CMUX" == "1" ]]; then + local cmux_key + for cmux_key in "${!CMUX_@}"; do + unset "$cmux_key" + done + unset TERMINFO + fi + exec "$REAL_CODEX" "$@" +} + +# Opt-out, outside cmux, or stale/dead socket: pass straight through. +if [[ "${CMUX_CODEX_HOOKS_DISABLED:-}" == "1" ]]; then + exec_real_codex_passthrough "$@" +fi +if [[ "$IN_CMUX" == "0" ]] || ! cmux_socket_available; then + exec_real_codex_passthrough "$@" +fi + +# Not a session entrypoint (resume/doctor/--help/...): pass through unchanged. +if ! should_inject_codex_hooks "$@"; then + exec_real_codex_passthrough "$@" +fi + +# Export launch identity so the hook subprocesses and cmux's agent-pid / +# ended-detection can bind this Codex to its surface and pid. Because we exec +# codex below, $$ becomes the real codex pid. +export CMUX_CODEX_PID=$$ +export CMUX_CODEX_HOOK_CMUX_BIN="$(resolve_hook_cmux_bin)" +export CMUX_AGENT_LAUNCH_KIND="codex" +export CMUX_AGENT_LAUNCH_EXECUTABLE="$REAL_CODEX" +export CMUX_AGENT_LAUNCH_CWD="$PWD" +{ + cmux_codex_argv_b64="$( + { + printf '%s\0' "$REAL_CODEX" + if (( $# > 0 )); then + printf '%s\0' "$@" + fi + } | base64 | tr -d '\n' + )" + [[ -n "$cmux_codex_argv_b64" ]] && export CMUX_AGENT_LAUNCH_ARGV_B64="$cmux_codex_argv_b64" +} + +# Best-effort, one-way launch signal BEFORE exec. Detection happens at launch +# even if Codex's own SessionStart is delayed or the injection path is imperfect. +# The wrapper has no Codex session_id yet, so it sends only surface/pid/cwd; the +# registry dedups so Codex's own SessionStart (with the real id) reconciles it. +# Fully fire-and-forget: never block startup, never fail the launch. +cmux_codex_fire_launch_session_start() { + local cmux_bin="$CMUX_CODEX_HOOK_CMUX_BIN" + [[ -n "$cmux_bin" ]] || return 0 + local -a base=() + if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then + base=("$cmux_bin" --socket "$CMUX_SOCKET_PATH") + else + base=("$cmux_bin") + fi + # `{}` on stdin: an empty hook payload. The handler falls back to env + # (CMUX_SURFACE_ID / CMUX_CODEX_PID) for binding when stdin carries no id. + CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 \ + "${base[@]}" hooks codex session-start /dev/null 2>&1 & + disown 2>/dev/null || true +} +cmux_codex_fire_launch_session_start + +# Build the per-invocation [hooks] injection. Each event runs the SAME gated +# command cmux installs into ~/.codex when hooks are persisted: resolve the cmux +# CLI, require CMUX_SURFACE_ID + a live socket + hooks not disabled, then run +# `cmux hooks codex `; otherwise emit `{}` (a valid empty hook result). +# Carried as a TOML multi-line literal string ('''...''') so the embedded single +# quotes in the gated command need no escaping (the command never contains '''). +cmux_codex_hook_command() { + local sub="$1" + printf '%s' "cmux_cli=\"\${CMUX_CODEX_HOOK_CMUX_BIN:-\${CMUX_BUNDLED_CLI_PATH:-}}\"; if [ -z \"\$cmux_cli\" ] || [ ! -x \"\$cmux_cli\" ]; then cmux_cli=\"\$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"\$CMUX_SURFACE_ID\" ] && [ \"\$CMUX_CODEX_HOOKS_DISABLED\" != \"1\" ] && [ -n \"\$cmux_cli\" ]; then { if [ -n \"\${CMUX_SOCKET_PATH:-}\" ]; then \"\$cmux_cli\" --socket \"\$CMUX_SOCKET_PATH\" hooks codex $sub; else \"\$cmux_cli\" hooks codex $sub; fi; } || echo '{}'; else echo '{}'; fi" +} + +cmux_codex_hook_toml() { + # $1 = codex event name (SessionStart, Stop, ...), $2 = cmux subcommand, + # $3 = timeout ms. + # + # The command is wrapped in a TOML multi-line literal string (three single + # quotes), which preserves bytes verbatim and may contain single quotes, so + # the gated command's `'{}'` and `'$(...)'` survive with no escaping. TOML + # forbids only a literal ''' inside; the gated command never contains one. + local event="$1" sub="$2" timeout="$3" + local cmd lit + cmd="$(cmux_codex_hook_command "$sub")" + lit="'''" + printf 'hooks.%s=[{hooks=[{type="command",command=%s%s%s,timeout=%s}]}]' \ + "$event" "$lit" "$cmd" "$lit" "$timeout" +} + +# Assemble the -c overrides. We pass each event as its own -c so a parse problem +# in one cannot drop the others. Timeouts: lifecycle events short, feed +# (PreToolUse/PermissionRequest) long because the user may take time to approve. +cmux_codex_hook_args=() +cmux_codex_add_hook() { + local event="$1" sub="$2" timeout="$3" + cmux_codex_hook_args+=(-c "$(cmux_codex_hook_toml "$event" "$sub" "$timeout")") +} +cmux_codex_add_hook "SessionStart" "session-start" 10000 +cmux_codex_add_hook "UserPromptSubmit" "prompt-submit" 10000 +cmux_codex_add_hook "Stop" "stop" 10000 +cmux_codex_add_hook "PreToolUse" "pre-tool-use" 120000 +cmux_codex_add_hook "PostToolUse" "post-tool-use" 10000 +cmux_codex_add_hook "PermissionRequest" "notification" 120000 + +# `--enable hooks` turns the feature on for this run; `--dangerously-bypass-hook-trust` +# lets the injected hooks run without persisted trust (automation, our own hooks). +exec "$REAL_CODEX" \ + --enable hooks \ + --dangerously-bypass-hook-trust \ + "${cmux_codex_hook_args[@]}" \ + "$@" diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 524be4fc0464..67b5e576fa7d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -186,6 +186,7 @@ C10D00010000000000000001 /* CloudVMActionLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00020000000000000002 /* CloudVMActionLauncher.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */; }; + C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */; }; A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; @@ -1111,6 +1112,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE20002A1B2C3D4E5F719 /* cmux-claude-wrapper in Copy CLI */, + C1ADE30002A1B2C3D4E5F719 /* cmux-codex-wrapper in Copy CLI */, C1ADE10002A1B2C3D4E5F719 /* grok in Copy CLI */, D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, C0DE70500000000000000002 /* start-cmux-profiling in Copy CLI */, @@ -1311,6 +1313,7 @@ B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-claude-wrapper"; sourceTree = SOURCE_ROOT; }; + C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/cmux-codex-wrapper"; sourceTree = SOURCE_ROOT; }; A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; @@ -2226,6 +2229,7 @@ B2E7294509CC42FE9191870E /* xterm-ghostty */, A5002001 /* THIRD_PARTY_LICENSES.md */, C1ADE20001A1B2C3D4E5F719 /* cmux-claude-wrapper */, + C1ADE30001A1B2C3D4E5F719 /* cmux-codex-wrapper */, C1ADE10001A1B2C3D4E5F719 /* grok */, C0DE70500000000000000001 /* start-cmux-profiling */, C0DE70530000000000000001 /* submit-cmux-profile */, From fbfc7da4242f900e98e96d5a85f4093f2347f396 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 21:48:10 -0700 Subject: [PATCH 12/38] codex detection: bind surface/transcript on live session-start via feed.push event (fix) A live codex/claude session record from chat.sessions.dump showed surface_id=None / transcript_path=None even though the hook store had both. The feed.push event carried workspace_id/cwd but no surface_id or transcript_path, and the .sessionStart store-backfill was suppressed and 30s-throttled, so a fresh (or short-lived `codex exec`) session stayed unbound until the next consult. Option A (timing-independent): carry the hook-resolved surface/transcript in the event itself. - WorkstreamEvent: add surfaceId (surface_id) and transcriptPath (transcript_path), mirroring workspaceId exactly (default nil, decodeIfPresent, encodeIfPresent, and via CodingKeys.allCases they stay in the knownKeys set). - AgentChatSessionRegistry.noteHookEvent: apply event.surfaceId and event.transcriptPath onto the record alongside workspaceId/cwd, so a live event binds immediately without waiting on the throttled store consult. - CLI sendFeedTelemetry: add surfaceId param and write surface_id + transcript_path (from parsedInput.transcriptPath) into the feed.push event. Thread the hook-RESOLVED target.surfaceId through sendAgentFeedTelemetry / sendAgentFeedTelemetryUnlessSuppressed at every agent-hook call site that has a resolved target in scope (session-start, prompt-submit, stop/notification, session-end via mapped.surfaceId). Verified live: a real `codex exec` session 019ef2cc-... appeared as a single non-fallback codex record with non-null surface_id and transcript_path in state idle, then transitioned to ended after the process exited. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/cmux.swift | 30 ++++++++++++------- .../Workstream/WorkstreamEvent.swift | 12 ++++++++ Resources/bin/cmux-codex-wrapper | 26 ++++------------ .../AgentChat/AgentChatSessionRegistry.swift | 6 ++++ 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fc8ecb9c0ef9..816d62153c3b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -29587,7 +29587,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, @@ -29595,6 +29595,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { subcommand: subcommand, parsedInput: input, workspaceId: workspaceId ?? workspaceArg(), + surfaceId: surfaceId, socketPassword: socketPassword ) } @@ -29610,11 +29611,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? { @@ -29820,7 +29821,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") @@ -30083,7 +30084,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" { @@ -30231,7 +30232,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 @@ -30551,7 +30552,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, @@ -30692,7 +30693,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30707,7 +30708,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { env: env ) #endif - sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId) + sendAgentFeedTelemetryUnlessSuppressed(workspaceId: workspaceId, surfaceId: surfaceId) print("{}") return } @@ -30854,7 +30855,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 { @@ -30862,7 +30863,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, @@ -30932,6 +30933,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) @@ -30955,6 +30957,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 { diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift index b3c1ee31cd79..dbdae3b388a8 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamEvent.swift @@ -12,6 +12,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { public let hookEventName: HookEventName public let source: String public let workspaceId: String? + public let surfaceId: String? + public let transcriptPath: String? public let cwd: String? public let toolName: String? public let toolInputJSON: String? @@ -26,6 +28,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { hookEventName: HookEventName, source: String, workspaceId: String? = nil, + surfaceId: String? = nil, + transcriptPath: String? = nil, cwd: String? = nil, toolName: String? = nil, toolInputJSON: String? = nil, @@ -39,6 +43,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = hookEventName self.source = source self.workspaceId = workspaceId + self.surfaceId = surfaceId + self.transcriptPath = transcriptPath self.cwd = cwd self.toolName = toolName self.toolInputJSON = toolInputJSON @@ -72,6 +78,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { case hookEventName = "hook_event_name" case source = "_source" case workspaceId = "workspace_id" + case surfaceId = "surface_id" + case transcriptPath = "transcript_path" case cwd case toolName = "tool_name" case toolInputJSON = "tool_input" @@ -87,6 +95,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { self.hookEventName = try c.decode(HookEventName.self, forKey: .hookEventName) self.source = try c.decode(String.self, forKey: .source) self.workspaceId = try c.decodeIfPresent(String.self, forKey: .workspaceId) + self.surfaceId = try c.decodeIfPresent(String.self, forKey: .surfaceId) + self.transcriptPath = try c.decodeIfPresent(String.self, forKey: .transcriptPath) self.cwd = try c.decodeIfPresent(String.self, forKey: .cwd) self.toolName = try c.decodeIfPresent(String.self, forKey: .toolName) self.context = try c.decodeIfPresent(WorkstreamContext.self, forKey: .context) @@ -117,6 +127,8 @@ public struct WorkstreamEvent: Codable, Sendable, Equatable { try c.encode(hookEventName, forKey: .hookEventName) try c.encode(source, forKey: .source) try c.encodeIfPresent(workspaceId, forKey: .workspaceId) + try c.encodeIfPresent(surfaceId, forKey: .surfaceId) + try c.encodeIfPresent(transcriptPath, forKey: .transcriptPath) try c.encodeIfPresent(cwd, forKey: .cwd) try c.encodeIfPresent(toolName, forKey: .toolName) try c.encodeIfPresent(context, forKey: .context) diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index df04dc4faac7..25308a2a6f8b 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -250,27 +250,11 @@ export CMUX_AGENT_LAUNCH_CWD="$PWD" [[ -n "$cmux_codex_argv_b64" ]] && export CMUX_AGENT_LAUNCH_ARGV_B64="$cmux_codex_argv_b64" } -# Best-effort, one-way launch signal BEFORE exec. Detection happens at launch -# even if Codex's own SessionStart is delayed or the injection path is imperfect. -# The wrapper has no Codex session_id yet, so it sends only surface/pid/cwd; the -# registry dedups so Codex's own SessionStart (with the real id) reconciles it. -# Fully fire-and-forget: never block startup, never fail the launch. -cmux_codex_fire_launch_session_start() { - local cmux_bin="$CMUX_CODEX_HOOK_CMUX_BIN" - [[ -n "$cmux_bin" ]] || return 0 - local -a base=() - if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then - base=("$cmux_bin" --socket "$CMUX_SOCKET_PATH") - else - base=("$cmux_bin") - fi - # `{}` on stdin: an empty hook payload. The handler falls back to env - # (CMUX_SURFACE_ID / CMUX_CODEX_PID) for binding when stdin carries no id. - CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 \ - "${base[@]}" hooks codex session-start /dev/null 2>&1 & - disown 2>/dev/null || true -} -cmux_codex_fire_launch_session_start +# NOTE: no wrapper-fired launch session-start. Codex's own injected SessionStart +# hook (below) fires within ~1s carrying Codex's REAL session_id, so it is the +# single authoritative signal. A pre-exec wrapper-fired session-start has no +# session id and made the handler mint a junk `fallback-*` record alongside the +# real one (a phantom duplicate in the GUI), so it is intentionally omitted. # Build the per-invocation [hooks] injection. Each event runs the SAME gated # command cmux installs into ~/.codex when hooks are persisted: resolve the cmux diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 3f1f322182c4..3c5c299d68ff 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -278,9 +278,15 @@ final class AgentChatSessionRegistry { if let workspaceID = event.workspaceId, !workspaceID.isEmpty { record.workspaceID = workspaceID } + if let surfaceID = event.surfaceId, !surfaceID.isEmpty { + record.surfaceID = surfaceID + } if let cwd = event.cwd, !cwd.isEmpty { record.workingDirectory = cwd } + if let transcriptPath = event.transcriptPath, !transcriptPath.isEmpty { + record.transcriptPath = transcriptPath + } record.lastActivityAt = event.receivedAt let previous = records[sessionID] From 4a1a8c86b9180a1daaa683c32df18c9bae6da9bc Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Mon, 22 Jun 2026 21:51:26 -0700 Subject: [PATCH 13/38] codex detection: plan status -> implemented + live-verified Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/codex-agent-detection-plan.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/codex-agent-detection-plan.md b/docs/codex-agent-detection-plan.md index c54c57fba66e..593f003aa362 100644 --- a/docs/codex-agent-detection-plan.md +++ b/docs/codex-agent-detection-plan.md @@ -1,6 +1,20 @@ # Codex Agent Detection Plan -Status: DRAFT. Owner: Aziz. Last updated: 2026-06-22. +Status: IMPLEMENTED + live-verified (macOS/daemon side). Owner: Aziz. Last +updated: 2026-06-22. Branch `feat-codex-detection`, PR #6655 (do-not-merge +pending on-device dogfood). Base/return point: tag `agent-session-sot-landmark`. + +Done: `cmux-codex-wrapper` (PATH shim, Claude-parity per-invocation `[hooks]` +injection via `--enable hooks --dangerously-bypass-hook-trust -c hooks.=...`), +codex PATH-shim install sibling to the claude shim, `WorkstreamEvent` + +`feed.push` + `noteHookEvent` now carry `surface_id`/`transcript_path`. Two +preflight-caught bugs fixed: phantom `fallback-*` duplicate (removed the +wrapper-fired empty-stdin launch signal) and unbound-surface on live +session-start. Live debug-socket proof: a real `codex exec` produced exactly one +codex session, surface + transcript bound, `idle -> ended` on exit. + +Remaining: on-device iOS GUI dogfood (the iPhone renders the registry; macOS +side proven). Codex `needsInput` is hook-driven via `PermissionRequest`. Expands Slice F of `agent-session-tracking-spec.md`: make Codex sessions track in the iOS GUI as reliably as Claude, without forcing users to install anything and From bef654d9e131de414a7a0c9cdb0b984e85404ccf Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 09:50:26 -0700 Subject: [PATCH 14/38] codex detection: fire-and-forget injected hooks so codex never blocks on cmux The codex wrapper injected per-invocation [hooks] whose command called `cmux hooks codex ` SYNCHRONOUSLY. Codex runs hooks synchronously and blocks until they return, so every launch hung ~35s on "Running SessionStart hook" and every prompt lagged on UserPromptSubmit while the cmux call did socket round-trips. Reuse cmux's proven fire-and-forget shape (CMUXCLI.codexFireAndForget- AgentHookShellCommand): capture codex's stdin payload to a temp file, nohup- background the cmux call with a 30s watchdog, and `echo '{}'` back to codex instantly. Detection still binds the real session_id/transcript_path because the backgrounded call gets codex's real stdin. Implementation: a hidden, socket-free `cmux hooks codex inject-args` emits the exact codex arg list (NUL-terminated) to enable + inject the fire-and-forget hooks for all six events (SessionStart, UserPromptSubmit, Stop, PreToolUse, PostToolUse, PermissionRequest), each fire-and-forget command carried in a TOML multi-line literal. The wrapper reads that stream into a bash array and execs codex with it, replacing the hand-rolled TOML/quoting in bash. All passthrough-safety gates (not in cmux / dead socket / hooks-disabled / non-session subcommand / emit fails) still fall back to plain `exec codex`. Two bugs found and fixed during live verification: the CLI emitted args NUL-SEPARATED (dropped the final PermissionRequest arg at EOF) -> now NUL-terminated; and the wrapper read via `raw="$(...)"` command substitution, which bash strips NUL bytes from, collapsing the stream and silently dropping the whole injection -> now reads the command directly via process substitution. Verified live: hook returns {} in ~0.01s, the codex session binds (surface_id + transcript_path) and goes ended after exit. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 58 ++++++++++++++++ CLI/cmux.swift | 6 ++ Resources/bin/cmux-codex-wrapper | 84 ++++++++++------------- 3 files changed, 102 insertions(+), 46 deletions(-) diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index ec20e9f28a52..c1e0c61482c9 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -1,4 +1,62 @@ +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.") + } + 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 + ) + // 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 (the command never has one). + guard !ff.contains("'''") else { + throw CLIError(message: "Codex fire-and-forget hook command contains a triple single quote and cannot be TOML-encoded.") + } + let toml = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(ff)''',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) + } + 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 816d62153c3b..74c7efe022e5 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33546,6 +33546,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/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index 25308a2a6f8b..f93565f3faed 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -256,52 +256,44 @@ export CMUX_AGENT_LAUNCH_CWD="$PWD" # session id and made the handler mint a junk `fallback-*` record alongside the # real one (a phantom duplicate in the GUI), so it is intentionally omitted. -# Build the per-invocation [hooks] injection. Each event runs the SAME gated -# command cmux installs into ~/.codex when hooks are persisted: resolve the cmux -# CLI, require CMUX_SURFACE_ID + a live socket + hooks not disabled, then run -# `cmux hooks codex `; otherwise emit `{}` (a valid empty hook result). -# Carried as a TOML multi-line literal string ('''...''') so the embedded single -# quotes in the gated command need no escaping (the command never contains '''). -cmux_codex_hook_command() { - local sub="$1" - printf '%s' "cmux_cli=\"\${CMUX_CODEX_HOOK_CMUX_BIN:-\${CMUX_BUNDLED_CLI_PATH:-}}\"; if [ -z \"\$cmux_cli\" ] || [ ! -x \"\$cmux_cli\" ]; then cmux_cli=\"\$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"\$CMUX_SURFACE_ID\" ] && [ \"\$CMUX_CODEX_HOOKS_DISABLED\" != \"1\" ] && [ -n \"\$cmux_cli\" ]; then { if [ -n \"\${CMUX_SOCKET_PATH:-}\" ]; then \"\$cmux_cli\" --socket \"\$CMUX_SOCKET_PATH\" hooks codex $sub; else \"\$cmux_cli\" hooks codex $sub; fi; } || echo '{}'; else echo '{}'; fi" -} - -cmux_codex_hook_toml() { - # $1 = codex event name (SessionStart, Stop, ...), $2 = cmux subcommand, - # $3 = timeout ms. - # - # The command is wrapped in a TOML multi-line literal string (three single - # quotes), which preserves bytes verbatim and may contain single quotes, so - # the gated command's `'{}'` and `'$(...)'` survive with no escaping. TOML - # forbids only a literal ''' inside; the gated command never contains one. - local event="$1" sub="$2" timeout="$3" - local cmd lit - cmd="$(cmux_codex_hook_command "$sub")" - lit="'''" - printf 'hooks.%s=[{hooks=[{type="command",command=%s%s%s,timeout=%s}]}]' \ - "$event" "$lit" "$cmd" "$lit" "$timeout" +# Build the per-invocation [hooks] injection. The cmux CLI emits the exact arg +# list (NUL-separated) that enables hooks and injects cmux's FIRE-AND-FORGET hook +# command for each event. Fire-and-forget is critical: codex runs hooks +# synchronously and BLOCKS until they return, so a synchronous `cmux hooks codex +# ` made every launch hang ~35s on "Running SessionStart hook". The emitted +# command captures codex's stdin payload to a temp file, nohup-backgrounds the +# real cmux call with a 30s watchdog, and `echo '{}'` returns to codex instantly, +# so detection still binds the real session id while codex never blocks. +# +# Resolving the args via the CLI (instead of re-deriving fragile shell here) +# avoids quoting bugs in the fire-and-forget command (it contains single quotes +# like `echo '{}'` and `sh -c '...'`). If the emit fails or yields nothing, we +# fall back to plain passthrough so installing the wrapper can never break codex. +CMUX_INJECT_CLI="$CMUX_CODEX_HOOK_CMUX_BIN" +cmux_codex_injected_args=() +cmux_codex_read_inject_args() { + [[ -n "$CMUX_INJECT_CLI" && -x "$CMUX_INJECT_CLI" ]] || return 1 + local arg + # Read the NUL-TERMINATED arg stream DIRECTLY via process substitution. A + # `raw="$(... )"` command substitution must NOT be used: bash discards every + # NUL byte from command-substitution output (with a warning), which would + # collapse the whole stream into one token and silently drop the injection. + if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then + while IFS= read -r -d '' arg; do + cmux_codex_injected_args+=("$arg") + done < <("$CMUX_INJECT_CLI" --socket "$CMUX_SOCKET_PATH" hooks codex inject-args 2>/dev/null) + else + while IFS= read -r -d '' arg; do + cmux_codex_injected_args+=("$arg") + done < <("$CMUX_INJECT_CLI" hooks codex inject-args 2>/dev/null) + fi + (( ${#cmux_codex_injected_args[@]} > 0 )) || return 1 + return 0 } -# Assemble the -c overrides. We pass each event as its own -c so a parse problem -# in one cannot drop the others. Timeouts: lifecycle events short, feed -# (PreToolUse/PermissionRequest) long because the user may take time to approve. -cmux_codex_hook_args=() -cmux_codex_add_hook() { - local event="$1" sub="$2" timeout="$3" - cmux_codex_hook_args+=(-c "$(cmux_codex_hook_toml "$event" "$sub" "$timeout")") -} -cmux_codex_add_hook "SessionStart" "session-start" 10000 -cmux_codex_add_hook "UserPromptSubmit" "prompt-submit" 10000 -cmux_codex_add_hook "Stop" "stop" 10000 -cmux_codex_add_hook "PreToolUse" "pre-tool-use" 120000 -cmux_codex_add_hook "PostToolUse" "post-tool-use" 10000 -cmux_codex_add_hook "PermissionRequest" "notification" 120000 +if cmux_codex_read_inject_args; then + exec "$REAL_CODEX" "${cmux_codex_injected_args[@]}" "$@" +fi -# `--enable hooks` turns the feature on for this run; `--dangerously-bypass-hook-trust` -# lets the injected hooks run without persisted trust (automation, our own hooks). -exec "$REAL_CODEX" \ - --enable hooks \ - --dangerously-bypass-hook-trust \ - "${cmux_codex_hook_args[@]}" \ - "$@" +# Emit failed or returned nothing: never break codex, just pass through. +exec_real_codex_passthrough "$@" From bd85a6637c7010bed8366844a5a18c9f77c65f7b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 11:23:56 -0700 Subject: [PATCH 15/38] agent-session: scope mobile chat sessions by surface's current workspace, not stale stored workspace_id cmux workspace ids regenerate on every Mac relaunch while surface ids are stable and rehydrate verbatim, so a chat session created before the last relaunch carries a stale stored workspace_id and was dropped from its terminal's current workspace (no iOS chat toggle). Scope the workspace- filtered mobile.chat.sessions listing by the surface's CURRENT workspace: resolve the requested workspace, return every session whose surface is a live terminal panel there and that matches its agent against that workspace+panel, and re-stamp each returned record to the requested workspace so the seed and live descriptorChanged pushes both scope to it. Also exposes mobile.chat.sessions over the local control/debug socket for dogfood verification of this path. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ControlCommandCoordinator+MobileHost.swift | 2 + .../MobileHost/ControlMobileHostContext.swift | 8 ++ .../AgentChatTranscriptService.swift | 15 ++++ ...lController+ControlMobileHostContext.swift | 4 + Sources/TerminalController+MobileChat.swift | 83 +++++++++++++++++-- 5 files changed, 106 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index bfdcbd4d3077..1955c100f979 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -53,6 +53,8 @@ extension ControlCommandCoordinator { return context?.controlMobileTerminalPaste(params: request.params) case "chat.sessions.dump": return context?.controlMobileChatSessionsDump() + case "mobile.chat.sessions": + return context?.controlMobileChatSessions(params: request.params) default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index ecec781834ac..4b185fd7439d 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -122,4 +122,12 @@ public protocol ControlMobileHostContext: AnyObject { /// /// - Returns: The fully-built command result. func controlMobileChatSessionsDump() -> ControlCallResult + + /// `mobile.chat.sessions` (local debug socket) — the chat-capable session + /// list as the phone would see it for one workspace, for diagnosing which + /// sessions surface a chat toggle. Same body the mobile data plane runs. + /// + /// - Parameter params: The decoded request params (`workspace_id`). + /// - Returns: The fully-built command result. + func controlMobileChatSessions(params: [String: JSONValue]) -> ControlCallResult } diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index aa0b4a12ce44..2dedff377ae1 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -107,6 +107,21 @@ final class AgentChatTranscriptService { await registry.refreshBindingsFromHookStore(sessionID: sessionID) } + /// Re-stamps a session's stored workspace id to the workspace its surface + /// currently lives in. cmux workspace ids regenerate on every Mac relaunch + /// while surface ids are stable, so a session created before the last + /// relaunch carries a stale `workspaceID`. The caller resolves the session's + /// live surface to its current workspace and calls this so the seed and the + /// live `descriptorChanged` pushes both scope to that workspace (the iOS + /// reducer is workspace-scoped and rejects stale-workspace live updates). + /// + /// - Parameters: + /// - sessionID: The session to re-stamp. + /// - workspaceID: The surface's current workspace UUID string. + func updateSessionWorkspace(sessionID: String, workspaceID: String) { + registry.update(sessionID: sessionID) { $0.workspaceID = workspaceID } + } + /// Serves one history page, starting the session's tailer on demand. /// /// - Parameters: diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index f550acde844b..d08f3ce4d86b 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -65,6 +65,10 @@ extension TerminalController: ControlMobileHostContext { bridgeMobileResult(v2ChatSessionsDump()) } + func controlMobileChatSessions(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileChatSessions(params: foundationParams(params))) + } + /// Reconstructs the legacy `[String: Any]` params from the coordinator's /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index ad59c970aeb0..6d5f80e38ddb 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -62,15 +62,63 @@ extension TerminalController { /// `mobile.chat.sessions`: list chat-capable coding-agent sessions, /// optionally scoped to one workspace. + /// + /// When a `workspace_id` W is given, sessions are scoped by the SURFACE'S + /// CURRENT workspace, never the record's stored `workspaceID`. cmux + /// workspace ids regenerate on every Mac relaunch while surface ids are + /// stable, so a session created before the last relaunch carries a stale + /// stored `workspaceID` and would otherwise be dropped from its terminal's + /// current workspace. We resolve W once, collect W's live terminal surface + /// ids once, then return every session whose surface is one of them and that + /// still matches its agent against THAT workspace+panel. Each returned + /// session is re-stamped to W so its seed and live `descriptorChanged` + /// pushes both scope to the current workspace. func v2MobileChatSessions(params: [String: Any]) -> V2CallResult { let workspaceID = v2String(params, "workspace_id") guard let service = agentChatTranscriptService else { return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } - let descriptors = service.sessionRecords(workspaceID: workspaceID) - .filter { mobileChatBindingIsCurrentAgent($0) } - .map(\.descriptor) - let encoded = descriptors.compactMap { service.wirePayload($0) } + guard let workspaceID else { + // No filter: return all current-agent sessions across workspaces, + // resolving each via its stored binding as before. + let descriptors = service.sessionRecords(workspaceID: nil) + .filter { mobileChatBindingIsCurrentAgent($0) } + .map(\.descriptor) + let encoded = descriptors.compactMap { service.wirePayload($0) } + return .ok(["sessions": encoded]) + } + // Resolve W to its live Workspace once; build the set of its live + // terminal surface ids once, then filter sessions against that set. + guard let resolved = mobileResolveWorkspaceAndSurface( + params: ["workspace_id": workspaceID], + requireTerminal: false + ) else { + return .ok(["sessions": []]) + } + let workspace = resolved.workspace + var encoded: [[String: Any]] = [] + for record in service.sessionRecords(workspaceID: nil) { + guard let surfaceID = record.surfaceID, + let surfaceUUID = UUID(uuidString: surfaceID), + let terminalPanel = workspace.terminalPanel(for: surfaceUUID), + mobileChatRecordMatchesAgent( + record: record, + workspace: workspace, + terminalPanel: terminalPanel + ) else { + continue + } + // Re-stamp stale-workspace records to W so the seed and live pushes + // both scope to the current workspace, then encode the re-stamped + // descriptor. + if record.workspaceID != workspaceID { + service.updateSessionWorkspace(sessionID: record.sessionID, workspaceID: workspaceID) + } + let descriptor = service.sessionRecord(sessionID: record.sessionID)?.descriptor ?? record.descriptor + if let payload = service.wirePayload(descriptor) { + encoded.append(payload) + } + } return .ok(["sessions": encoded]) } @@ -324,6 +372,13 @@ extension TerminalController { /// represents. This prevents a stale registry surface id from exposing a /// chat toggle or routing prompts into a plain shell after a terminal was /// restored/reused. + /// + /// Resolves the terminal via the record's STORED `workspaceID`, which is + /// the very value that goes stale after a Mac relaunch — so use this only + /// for the no-filter path. The workspace-filtered path + /// (``v2MobileChatSessions``) resolves the surface to its CURRENT workspace + /// and calls ``mobileChatRecordMatchesAgent(record:workspace:terminalPanel:)`` + /// directly. private func mobileChatBindingIsCurrentAgent(_ record: AgentChatSessionRecord) -> Bool { guard let workspaceID = record.workspaceID, let surfaceID = record.surfaceID, @@ -335,9 +390,25 @@ extension TerminalController { let terminalPanel = resolved.workspace.terminalPanel(for: surfaceId) else { return false } - let title = resolved.workspace.panelTitle(panelId: terminalPanel.id) ?? terminalPanel.displayTitle + return mobileChatRecordMatchesAgent( + record: record, + workspace: resolved.workspace, + terminalPanel: terminalPanel + ) + } + + /// Agent-match core: whether an already-resolved `(workspace, terminalPanel)` + /// still looks like the agent the record represents. Resolution-free so the + /// workspace-filtered listing path can call it with the surface's CURRENT + /// workspace rather than the record's stale stored one. + private func mobileChatRecordMatchesAgent( + record: AgentChatSessionRecord, + workspace: Workspace, + terminalPanel: TerminalPanel + ) -> Bool { + let title = workspace.panelTitle(panelId: terminalPanel.id) ?? terminalPanel.displayTitle let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let context = WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: resolved.workspace) + let context = WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: workspace) switch record.agentKind { case .claude: return TextBoxAgentDetection.isClaudeCode(context: context) From e35ae957c221723386a4cc69c4f01f29c8c377c3 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 11:43:41 -0700 Subject: [PATCH 16/38] agent-session: retain ended sessions in mobile.chat.sessions; re-pin to reopened live session Ended sessions were dropped from the workspace list because the is-current-agent check requires the terminal to be running the agent; that contradicts the retained-ended GUI and made the toggle go stale + vanish on tap after the agent exited. Now ended sessions are kept whenever their surface is a live terminal in the workspace (live sessions still require the agent match). iOS re-pins from an ended pinned session to a newer live session on the same terminal so reopening the agent makes the GUI editable again. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WorkspaceDetailView.swift | 22 +++++++++++++++++++ Sources/TerminalController+MobileChat.swift | 22 ++++++++++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 039eba832212..7c0433e0aef3 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -254,14 +254,36 @@ struct WorkspaceDetailView: View { // wire is the "appears real quickly but not smooth" moment). let seeded = (try? await source.sessions(workspaceID: workspace.id.rawValue)) ?? [] withAnimation(.snappy(duration: 0.25)) { chatSessions = seeded } + repinToReopenedSession() applyChatModeFallback() for await frame in stream { let next = reducer.applying(frame, to: chatSessions) withAnimation(.snappy(duration: 0.25)) { chatSessions = next } + repinToReopenedSession() applyChatModeFallback() } } + /// While chat is open and pinned to a session that has ENDED, if the agent + /// was reopened on the same terminal (a newer, non-ended session bound to + /// the same terminal id), re-pin to it so the GUI becomes editable again. + /// Only an ended pin is switched, never a live one, so an active read is + /// never swapped out; `.id(session.id)` rebuilds the conversation store for + /// the new session. + private func repinToReopenedSession() { + guard isChatMode, + let pinnedID = pinnedChatSessionID, + let pinned = chatSessions.first(where: { $0.id == pinnedID }), + pinned.state == .ended, + let terminalID = pinned.terminalID else { return } + let live = chatSessions + .filter { $0.terminalID == terminalID && $0.id != pinnedID && $0.state != .ended } + .max { ($0.lastActivityAt ?? .distantPast) < ($1.lastActivityAt ?? .distantPast) } + if let live { + pinnedChatSessionID = live.id + } + } + /// If the session backing chat mode disappeared, fall back to the /// terminal rather than showing an empty chat. private func applyChatModeFallback() { diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 6d5f80e38ddb..ba38a686cba7 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -100,12 +100,22 @@ extension TerminalController { for record in service.sessionRecords(workspaceID: nil) { guard let surfaceID = record.surfaceID, let surfaceUUID = UUID(uuidString: surfaceID), - let terminalPanel = workspace.terminalPanel(for: surfaceUUID), - mobileChatRecordMatchesAgent( - record: record, - workspace: workspace, - terminalPanel: terminalPanel - ) else { + let terminalPanel = workspace.terminalPanel(for: surfaceUUID) else { + continue + } + // A LIVE session must still be the current agent on the terminal, so + // a reused/restored terminal never exposes a false live toggle. An + // ENDED session is RETAINED whenever its surface is a live terminal + // in W, regardless of what the terminal runs now: the GUI keeps a + // finished conversation visible read-only (input bar disabled), so a + // fresh pull must not drop it — dropping it is what made the toggle + // go stale and vanish on tap after the agent exited. + if record.state != .ended, + !mobileChatRecordMatchesAgent( + record: record, + workspace: workspace, + terminalPanel: terminalPanel + ) { continue } // Re-stamp stale-workspace records to W so the seed and live pushes From da573c9503a28e134500a827f1e92afe1d2f20b0 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 13:45:33 -0700 Subject: [PATCH 17/38] codex detection: route codex resume through cmux-codex-wrapper so resumed sessions keep hooks (editable in GUI) Mirror claude's wrapper-shim resume mechanism for codex. On Mac relaunch the restore launcher replayed a bare `codex resume `, which resolved to the real codex binary inside the `$SHELL -lic` shell, bypassing cmux-codex-wrapper. No hooks fired, no SessionStart, the registry never marked the resumed session live, and the iOS GUI stayed read-only. - AgentResumeArgv: add codexWrapperShellExecutableToken (resolves CMUX_CODEX_WRAPPER_SHIM, degrades to bare codex) plus portable/render helpers, mirroring the claude token + /bin/sh -c wrapping for fish/csh. - TerminalSurfaceClaudeCommandShim: carry the sibling codex shim so the install result plumbs it forward. - TerminalSurface+RuntimeSurfaceCreation: export CMUX_CODEX_WRAPPER_SHIM (+_ROOT) into the managed env alongside the claude shim, so the restore launcher inherits it (previously only set in a sourced snippet). - SessionIndexModels + RestorableAgentSession (AgentResumeCommandBuilder): render the first bare codex token as the wrapper token and wrap in /bin/sh -c, exactly like claude. Full-path codex executables are unaffected. - SurfaceResumeCommandCanonicalizer: route a stale codex executable through the codex wrapper token too (generalized the claude path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CMUXAgentLaunch/AgentResumeArgv.swift | 79 +++++++++++++++++++ .../AgentResumeArgvTests.swift | 36 +++++++++ .../TerminalSurface+StartupEnvironment.swift | 10 ++- ...rminalSurface+RuntimeSurfaceCreation.swift | 11 +++ .../TerminalSurfaceClaudeCommandShim.swift | 17 +++- Sources/RestorableAgentSession.swift | 31 ++++++-- Sources/SessionIndexModels.swift | 17 +++- ...anonicalizer+PortableAgentExecutable.swift | 65 ++++++++++----- .../RestorableAgentSessionIndexTests.swift | 18 ++++- cmuxTests/SessionIndexViewTests.swift | 45 ++++++++--- 10 files changed, 281 insertions(+), 48 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift index d852adddd1cb..39f52af2731a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift @@ -51,6 +51,33 @@ public struct AgentResumeArgv: Sendable, Equatable { public static let claudeWrapperShellExecutableToken = "\"$([ -x \"${CMUX_CLAUDE_WRAPPER_SHIM:-}\" ] && printf '%s' \"$CMUX_CLAUDE_WRAPPER_SHIM\" || printf claude)\"" + /// The shell token that resolves cmux's `codex` wrapper at exec time. + /// + /// The codex resume argv emits a bare `codex` executable, but the captured + /// auto-resume command (`codex resume `) resolves to the *real* codex + /// binary inside the `$SHELL -lic` restore launcher, bypassing + /// `cmux-codex-wrapper` and dropping every cmux hook (no `SessionStart`, the + /// session registry never marks the resumed session live, so the iOS GUI + /// stays read-only). This mirrors the claude wrapper-shim mechanism: the + /// per-surface `codex` shim path is exported as the *managed* terminal + /// environment variable `CMUX_CODEX_WRAPPER_SHIM` (set by + /// `TerminalSurface+RuntimeSurfaceCreation`), inherited by every descendant + /// shell regardless of `PATH`/function shadowing, so resolving the codex + /// executable through it routes the resume command through the wrapper and + /// the hooks fire. https://github.com/manaflow-ai/cmux/issues/5639 + /// + /// The token guards on `[ -x … ]` rather than bare `${VAR:-codex}`: a + /// long-idle surface can hold the env var after macOS reaps the shim file, + /// and the executability guard degrades to bare `codex` (PATH resolution — + /// hooks lost but resume works), the same graceful fallback used when the + /// variable is unset outside cmux. + /// + /// Like the claude token, this is POSIX command substitution that fish and + /// csh/tcsh reject, so any command containing it must reach those shells + /// wrapped via ``portableCodexResumeShellCommand(posixCommand:)``. + public static let codexWrapperShellExecutableToken = + "\"$([ -x \"${CMUX_CODEX_WRAPPER_SHIM:-}\" ] && printf '%s' \"$CMUX_CODEX_WRAPPER_SHIM\" || printf codex)\"" + /// Wraps a rendered claude resume/fork command so it parses in any login shell. /// /// ``claudeWrapperShellExecutableToken`` is POSIX-only syntax, but the rendered @@ -117,6 +144,58 @@ public struct AgentResumeArgv: Sendable, Equatable { } } + /// Wraps a rendered codex resume command so it parses in any login shell. + /// + /// Mirror of ``portableClaudeResumeShellCommand(posixCommand:)`` for codex: + /// ``codexWrapperShellExecutableToken`` is POSIX-only command substitution, + /// but the rendered codex resume command is dispatched through the user's + /// `$SHELL` by the restore launcher and copy-pasted into the user's + /// interactive shell (fish/csh included), so wrapping it in + /// `/bin/sh -c ''` makes every dispatching shell parse it + /// identically while `sh` still inherits `CMUX_CODEX_WRAPPER_SHIM` from the + /// managed terminal environment (and falls back to bare `codex` when unset). + public static func portableCodexResumeShellCommand(posixCommand: String) -> String { + "/bin/sh -c " + posixSingleQuoted(posixCommand) + } + + /// Renders codex command `parts` through ``renderingCodexWrapperExecutable(parts:quote:)`` + /// and joins them, wrapping via ``portableCodexResumeShellCommand(posixCommand:)`` only + /// when the wrapper token was actually substituted. + /// + /// The `/bin/sh -c` layer exists solely to make the POSIX-only token parse in + /// non-POSIX shells, so it is applied exactly when the token is present; a + /// codex resume that emitted no bare `codex` executable stays unwrapped. + public static func renderedPortableCodexResumeShellCommand( + parts: [String], + quote: (String) -> String + ) -> String { + let rendered = renderingCodexWrapperExecutable(parts: parts, quote: quote) + let joined = rendered.joined(separator: " ") + guard rendered.contains(codexWrapperShellExecutableToken) else { return joined } + return portableCodexResumeShellCommand(posixCommand: joined) + } + + /// Renders shell command `parts` to quoted tokens, substituting + /// ``codexWrapperShellExecutableToken`` for the first bare `codex` executable token. + /// + /// Mirror of ``renderingClaudeWrapperExecutable(parts:quote:)`` for codex: only + /// the first element equal to `codex` — the wrapper executable emitted by the + /// codex resume builder — is replaced; every other token is quoted normally. + /// Call only for the codex kind. https://github.com/manaflow-ai/cmux/issues/5639 + public static func renderingCodexWrapperExecutable( + parts: [String], + quote: (String) -> String + ) -> [String] { + var replaced = false + return parts.map { part in + if !replaced, part == "codex" { + replaced = true + return codexWrapperShellExecutableToken + } + return quote(part) + } + } + /// The result of resolving a cmux wrapper launcher (the `claude-teams` / `codex-teams` / `omo` /// style launchers cmux injects), checked before the per-kind verb. public enum LauncherResolution: Sendable, Equatable { diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift index 87452b04ea95..3a5938190f57 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift @@ -221,4 +221,40 @@ struct AgentResumeArgvTests { ) == "'/Applications/cmux.app/Contents/Resources/bin/cmux' 'claude-teams' '--resume' 'SID'" ) } + + @Test("Codex wrapper token resolves CMUX_CODEX_WRAPPER_SHIM, degrading to bare codex") + func codexWrapperShellExecutableToken() { + #expect( + AgentResumeArgv.codexWrapperShellExecutableToken + == "\"$([ -x \"${CMUX_CODEX_WRAPPER_SHIM:-}\" ] && printf '%s' \"$CMUX_CODEX_WRAPPER_SHIM\" || printf codex)\"" + ) + } + + @Test("Portable codex resume command wraps the POSIX rendering for any login shell") + func portableCodexResumeShellCommand() { + #expect( + AgentResumeArgv.portableCodexResumeShellCommand(posixCommand: "codex resume SID") + == "/bin/sh -c 'codex resume SID'" + ) + } + + @Test("Rendered codex resume substitutes the wrapper token and wraps in /bin/sh -c") + func renderedPortableCodexResumeShellCommand() { + let quote: (String) -> String = { "'" + $0 + "'" } + // Bare `codex` executable: token substituted, command wrapped for non-POSIX shells. + let substituted = "\(AgentResumeArgv.codexWrapperShellExecutableToken) 'resume' 'SID'" + let rendered = AgentResumeArgv.renderedPortableCodexResumeShellCommand( + parts: ["codex", "resume", "SID"], + quote: quote + ) + #expect(rendered == "/bin/sh -c '" + substituted.replacingOccurrences(of: "'", with: "'\\''") + "'") + #expect(rendered.hasPrefix("/bin/sh -c ")) + // No bare `codex` executable: already-portable words stay unwrapped. + #expect( + AgentResumeArgv.renderedPortableCodexResumeShellCommand( + parts: ["/Applications/cmux.app/Contents/Resources/bin/cmux", "codex-teams", "resume", "SID"], + quote: quote + ) == "'/Applications/cmux.app/Contents/Resources/bin/cmux' 'codex-teams' 'resume' 'SID'" + ) + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift index 30c637c79fc5..8590782a7df8 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurface+StartupEnvironment.swift @@ -164,15 +164,19 @@ extension TerminalSurface { // dir so typed `codex` resolves to cmux-codex-wrapper through the // PATH entry already prepended for the claude shim. Failure here // never blocks the claude shim (codex detection degrades, claude is - // unaffected). - installCodexCommandShimIfPossible( + // unaffected). The returned codex shim is carried on the claude shim + // so runtime surface creation can export CMUX_CODEX_WRAPPER_SHIM into + // the managed env, which a resumed `codex` session needs to route its + // resume through the wrapper and keep cmux hooks. + let codexShim = installCodexCommandShimIfPossible( claudeWrapperURL: wrapperURL, shimDirectory: shimDirectory, fileManager: fileManager ) return ClaudeCommandShim( directoryPath: shimDirectory.path, - executablePath: shimURL.path + executablePath: shimURL.path, + codexCommandShim: codexShim ) } catch { return nil diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index 40a782e0e691..bef838fcf33f 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -151,6 +151,17 @@ extension TerminalSurface { if let claudeShim { setManagedEnvironmentValue("CMUX_CLAUDE_WRAPPER_SHIM", claudeShim.executablePath) setManagedEnvironmentValue("CMUX_CLAUDE_WRAPPER_SHIM_ROOT", claudeShim.directoryPath) + // Carry the sibling codex wrapper-shim path into the managed env too, + // mirroring the claude shim. The auto-resume command for a codex + // session resolves the codex executable through CMUX_CODEX_WRAPPER_SHIM + // (see AgentResumeArgv.codexWrapperShellExecutableToken), so without + // this the resumed codex bypasses cmux-codex-wrapper and loses its + // hooks (iOS GUI stays read-only). The shim lives in the same + // per-surface directory already prepended to PATH below. + if let codexShim = claudeShim.codexCommandShim { + setManagedEnvironmentValue("CMUX_CODEX_WRAPPER_SHIM", codexShim.executablePath) + setManagedEnvironmentValue("CMUX_CODEX_WRAPPER_SHIM_ROOT", codexShim.directoryPath) + } let currentPath = env["PATH"] ?? getenv("PATH").map { String(cString: $0) } ?? ProcessInfo.processInfo.environment["PATH"] diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swift index f441dd93a30e..b019bfde2ebf 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TerminalSurfaceClaudeCommandShim.swift @@ -10,13 +10,28 @@ public struct TerminalSurfaceClaudeCommandShim: Equatable, Sendable { /// The executable shim script inside ``directoryPath``. public let executablePath: String + /// The sibling `codex` wrapper shim installed in the same per-surface + /// directory, if the bundled `cmux-codex-wrapper` was available. Carried + /// alongside the claude shim so the runtime surface creation can export + /// `CMUX_CODEX_WRAPPER_SHIM` into the managed terminal environment (the + /// same way it exports `CMUX_CLAUDE_WRAPPER_SHIM`), which a resumed codex + /// session needs to route its `codex resume` through the wrapper and keep + /// cmux hooks. `nil` when the codex wrapper was absent. + public let codexCommandShim: TerminalSurfaceCodexCommandShim? + /// Creates a shim descriptor. /// /// - Parameters: /// - directoryPath: The per-surface shim directory prepended to `PATH`. /// - executablePath: The executable shim script inside the directory. - public init(directoryPath: String, executablePath: String) { + /// - codexCommandShim: The sibling codex wrapper shim, if installed. + public init( + directoryPath: String, + executablePath: String, + codexCommandShim: TerminalSurfaceCodexCommandShim? = nil + ) { self.directoryPath = directoryPath self.executablePath = executablePath + self.codexCommandShim = codexCommandShim } } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b6cdfc3c841c..cbd47fc566ff 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -389,19 +389,34 @@ enum AgentResumeCommandBuilder { workingDirectory: cwd ) : commandParts - // Render the claude executable as the wrapper shim token so the executed - // command routes through cmux's `claude` wrapper (re-injecting the hook - // --settings) even inside the `$SHELL -lic` restore launcher, where the - // shell integration's PATH shim / `claude()` function are not active and an - // `env`-prefixed invocation would otherwise hit the user's real binary. + // Render the claude/codex executable as the wrapper shim token so the + // executed command routes through cmux's `claude`/`codex` wrapper + // (re-injecting the agent hooks) even inside the `$SHELL -lic` restore + // launcher, where the shell integration's PATH shim / shell function are + // not active and an `env`-prefixed invocation would otherwise hit the + // user's real binary. Without this, an auto-resumed codex session runs the + // bare `codex` binary, fires no SessionStart hook, and the session registry + // never marks it live, so the iOS GUI stays read-only. // The token is POSIX-only, and the launcher dispatches through the user's // shell (fish/csh/tcsh included), so token-bearing commands are wrapped in // `/bin/sh -c '…'` to parse everywhere; the cwd guard stays outside so // cd-prefix rewriting keeps composing. // https://github.com/manaflow-ai/cmux/issues/5639 - let shellCommand = kind == .claude - ? AgentResumeArgv.renderedPortableClaudeResumeShellCommand(parts: sanitizedCommandParts, quote: shellSingleQuoted) - : sanitizedCommandParts.map(shellSingleQuoted).joined(separator: " ") + let shellCommand: String + switch kind { + case .claude: + shellCommand = AgentResumeArgv.renderedPortableClaudeResumeShellCommand( + parts: sanitizedCommandParts, + quote: shellSingleQuoted + ) + case .codex: + shellCommand = AgentResumeArgv.renderedPortableCodexResumeShellCommand( + parts: sanitizedCommandParts, + quote: shellSingleQuoted + ) + default: + shellCommand = sanitizedCommandParts.map(shellSingleQuoted).joined(separator: " ") + } return TerminalStartupWorkingDirectoryPrefix.prefix(shellCommand, workingDirectory: cwd) } diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index ee8ad1597b23..ec9c8955e30e 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -337,7 +337,18 @@ struct SessionEntry: Identifiable, Hashable { posixCommand: Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) ) case let .codex(model, approval, sandbox, effort): - var parts = ["codex resume \(sessionId)"] + // Route through the codex wrapper-resolver token so a manually- or + // auto-resumed codex session re-injects cmux hooks even when the + // command runs in a shell where the integration's PATH shim is not + // active (e.g. the `$SHELL -lic` restore launcher). Without this the + // bare `codex resume ` resolves to the real codex binary, + // bypassing cmux-codex-wrapper, so no SessionStart fires and the iOS + // GUI stays read-only. Mirror of the claude case: the token is + // POSIX-only and this command is typed into / copy-pasted into the + // user's own shell (fish/csh included), so the rendered command is + // wrapped in `/bin/sh -c '…'`; the `cd` guard stays outside in + // `resumeCommandWithCwd`. https://github.com/manaflow-ai/cmux/issues/5639 + var parts = ["\(AgentResumeArgv.codexWrapperShellExecutableToken) resume \(sessionId)"] if let model, !model.isEmpty { parts.append("-m \(Self.shellQuote(model))") } @@ -348,7 +359,9 @@ struct SessionEntry: Identifiable, Hashable { if let effort, !effort.isEmpty { parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") } - return parts.joined(separator: " ") + return AgentResumeArgv.portableCodexResumeShellCommand( + posixCommand: parts.joined(separator: " ") + ) case let .grok(model, permissionMode, sandboxMode, grokHome): var argv = ["grok", "-r", sessionId] if let model, !model.isEmpty { diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift index 701ab613b9a7..fbd442604bd2 100644 --- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift +++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift @@ -118,10 +118,28 @@ extension SurfaceResumeCommandCanonicalizer { } if executableName == "claude" { - return replacingStaleClaudeExecutable( + return replacingStaleWrapperRoutedExecutable( in: command, words: words, - executableIndex: executableIndex + executableIndex: executableIndex, + executableName: "claude", + wrapperToken: AgentResumeArgv.claudeWrapperShellExecutableToken, + renderPortable: { AgentResumeArgv.renderedPortableClaudeResumeShellCommand(parts: $0, quote: $1) }, + wrapInPortableShell: { AgentResumeArgv.portableClaudeResumeShellCommand(posixCommand: $0) } + ) + } else if executableName == "codex" { + // Mirror claude: route a stale codex executable (a PATH-managed path + // whose file is gone) through the codex wrapper token instead of a + // bare `codex`, so the restored codex surface keeps cmux hooks. + // https://github.com/manaflow-ai/cmux/issues/5639 + return replacingStaleWrapperRoutedExecutable( + in: command, + words: words, + executableIndex: executableIndex, + executableName: "codex", + wrapperToken: AgentResumeArgv.codexWrapperShellExecutableToken, + renderPortable: { AgentResumeArgv.renderedPortableCodexResumeShellCommand(parts: $0, quote: $1) }, + wrapInPortableShell: { AgentResumeArgv.portableCodexResumeShellCommand(posixCommand: $0) } ) } else { return replacingExecutableOnly( @@ -212,10 +230,14 @@ extension SurfaceResumeCommandCanonicalizer { path.withCString { access($0, X_OK) == 0 } } - private static func replacingStaleClaudeExecutable( + private static func replacingStaleWrapperRoutedExecutable( in command: String, words: [TerminalStartupWorkingDirectoryPrefix.ShellWordRange], - executableIndex: Int + executableIndex: Int, + executableName: String, + wrapperToken: String, + renderPortable: ([String], (String) -> String) -> String, + wrapInPortableShell: (String) -> String ) -> String { let commandStartIndex = commandStartWordIndex(in: words) guard commandStartIndex < words.count, @@ -228,50 +250,51 @@ extension SurfaceResumeCommandCanonicalizer { command: command, commandStartIndex: commandStartIndex ) else { - return replacingStaleClaudeExecutableWithWrapperShellCommand( + return replacingStaleExecutableWithWrapperShellCommand( in: command, words: words, commandStartIndex: commandStartIndex, - executableIndex: executableIndex + executableIndex: executableIndex, + wrapperToken: wrapperToken, + wrapInPortableShell: wrapInPortableShell ) } - guard canRenderStaleClaudeCommandAsPortableArgv( + guard canRenderStaleCommandAsPortableArgv( words: words, command: command, commandStartIndex: commandStartIndex, executableIndex: executableIndex ) else { - return replacingStaleClaudeExecutableWithWrapperShellCommand( + return replacingStaleExecutableWithWrapperShellCommand( in: command, words: words, commandStartIndex: commandStartIndex, - executableIndex: executableIndex + executableIndex: executableIndex, + wrapperToken: wrapperToken, + wrapInPortableShell: wrapInPortableShell ) } - parts[executableIndex - commandStartIndex] = "claude" - let renderedCommand = AgentResumeArgv.renderedPortableClaudeResumeShellCommand( - parts: parts, - quote: shellQuoted - ) + parts[executableIndex - commandStartIndex] = executableName + let renderedCommand = renderPortable(parts, shellQuoted) let commandStart = words[commandStartIndex].range.lowerBound return String(command[.. String ) -> String { let renderedParts = words[commandStartIndex...].indices.map { index in if index == executableIndex { - return AgentResumeArgv.claudeWrapperShellExecutableToken + return wrapperToken } return renderedPortableShellWord(words[index], in: command) } - let renderedCommand = AgentResumeArgv.portableClaudeResumeShellCommand( - posixCommand: renderedParts.joined(separator: " ") - ) + let renderedCommand = wrapInPortableShell(renderedParts.joined(separator: " ")) let commandStart = words[commandStartIndex].range.lowerBound return String(command[..'` wrapper (undoing the + /// `'\''` single-quote escaping). + static func unwrapPortableShellCommand(_ command: String) -> String { + let prefix = "/bin/sh -c " + guard command.hasPrefix(prefix) else { return command } + var quoted = String(command.dropFirst(prefix.count)) + guard quoted.hasPrefix("'"), quoted.hasSuffix("'") else { return quoted } + quoted = String(quoted.dropFirst().dropLast()) + return quoted.replacingOccurrences(of: "'\\''", with: "'") + } + func testCurrentDirectorySetterDoesNotPublishEqualValue() { let store = SessionIndexStore() var emittedValues: [String?] = [] From fd4136857fd75c0f95bd671a281fd1d8097c6375 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 15:37:25 -0700 Subject: [PATCH 18/38] codex detection: wrapper fires synthetic session-start on resume codex does NOT fire its own SessionStart hook when resuming a session, so a resumed codex never re-binds: the registry keeps the stale pre-relaunch record whose pid is already dead, the exit watcher flips it to .ended, and the iOS chat shows it read-only with no input bar (and the GUI can't recover, since you can't submit a prompt from a composer that isn't shown). The wrapper, unlike codex, knows the resumed session id (it is in argv) and the new live pid ($$), so it fires the session-start itself, fire-and-forget. The handler binds surface/workspace/cwd from the cmux env and pid from CMUX_CODEX_PID, re-binding the resumed session to its live pid and flipping it back to idle/editable. Also inject hooks on resume so subsequent turn events keep state accurate (codex does fire those on resume). Verified: resuming a session through the wrapper flips its store/registry pid from the dead original to the live process, with no phantom fallback-* record. Co-Authored-By: Claude Opus 4.8 (1M context) --- Resources/bin/cmux-codex-wrapper | 85 +++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 13 deletions(-) diff --git a/Resources/bin/cmux-codex-wrapper b/Resources/bin/cmux-codex-wrapper index f93565f3faed..5f84c0543816 100755 --- a/Resources/bin/cmux-codex-wrapper +++ b/Resources/bin/cmux-codex-wrapper @@ -105,14 +105,22 @@ resolve_hook_cmux_bin() { printf '%s' "cmux" } -# Only the interactive session and `exec`/`e` entrypoints start a Codex session; -# everything else (resume, review, login, mcp, doctor, --help, --version, ...) -# must pass through untouched so it never receives session/hook flags. Mirrors -# should_inject_claude_hooks: a leading non-option token that is a known Codex -# subcommand means "not a session" unless it is the exec alias. +# The interactive session, `exec`/`e`, and `resume` entrypoints all START a +# Codex session, so all three must receive cmux's hook injection; everything +# else (review, login, mcp, doctor, --help, --version, ...) passes through +# untouched. `resume` is critical: cmux auto-resumes a session as `codex resume +# ` after a Mac relaunch, and without hooks the resumed process fires no +# SessionStart, so the registry keeps only the stale pre-relaunch record (its +# old pid is dead -> seeded `.ended`) and the iOS GUI shows it read-only with no +# input bar. Injecting hooks on resume lets the live resumed session re-bind and +# flip back to idle/editable. codex accepts the global `--enable hooks` / +# `--dangerously-bypass-hook-trust` / `-c hooks.X=...` flags before the `resume` +# subcommand, so prepending them is safe. Mirrors should_inject_claude_hooks: a +# leading non-option token that is a known Codex subcommand means "not a session" +# unless it is exec or resume. codex_subcommand_starts_session() { case "$1" in - exec|e) return 0 ;; + exec|e|resume) return 0 ;; *) return 1 ;; esac } @@ -153,7 +161,8 @@ codex_passthrough_option_flag() { # Decide whether this invocation is a Codex SESSION entrypoint we should inject # hooks into. Bare `codex` (no args) and `codex [prompt]` are interactive -# sessions; `codex exec ...` is a non-interactive session; any other leading +# sessions; `codex exec ...` is a non-interactive session; `codex resume ...` +# (picker, , --last, --all ) resumes a session; any other leading # subcommand is not. should_inject_codex_hooks() { (( $# == 0 )) && return 0 @@ -192,6 +201,32 @@ should_inject_codex_hooks() { return 0 } +# Extract the resumed session id from a `codex resume ...` argv. codex does NOT +# fire its own SessionStart hook when resuming a session, so without this the GUI +# never learns the resumed session's new live pid: it keeps the stale +# pre-relaunch record whose pid is already dead, the exit watcher flips it to +# `.ended`, and the iOS chat shows it read-only with no input bar. The wrapper, +# unlike codex, knows the resumed id (it is in argv) and the new live pid ($$), +# so it fires the session-start itself (below). The id is the first UUID-shaped +# token after the `resume` subcommand; flags (`--all`, `-c k=v`, `--last`, ...) +# are skipped because they are not UUID-shaped. `codex resume` with no id (the +# interactive picker) and `--last` carry no id at launch, so this returns +# non-zero and those are left to codex. +cmux_codex_resume_session_id() { + local seen_resume=false arg + for arg in "$@"; do + if [[ "$seen_resume" == true ]]; then + if [[ "$arg" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then + printf '%s' "$arg" + return 0 + fi + elif [[ "$arg" == "resume" ]]; then + seen_resume=true + fi + done + return 1 +} + # Whether the invocation uses the non-interactive `exec`/`e` subcommand, which # requires --skip-git-repo-check tolerance differently; we never add flags codex # would reject, so this only gates the best-effort wrapper-fired session-start @@ -225,7 +260,7 @@ if [[ "$IN_CMUX" == "0" ]] || ! cmux_socket_available; then exec_real_codex_passthrough "$@" fi -# Not a session entrypoint (resume/doctor/--help/...): pass through unchanged. +# Not a session entrypoint (review/login/doctor/--help/...): pass through. if ! should_inject_codex_hooks "$@"; then exec_real_codex_passthrough "$@" fi @@ -250,11 +285,35 @@ export CMUX_AGENT_LAUNCH_CWD="$PWD" [[ -n "$cmux_codex_argv_b64" ]] && export CMUX_AGENT_LAUNCH_ARGV_B64="$cmux_codex_argv_b64" } -# NOTE: no wrapper-fired launch session-start. Codex's own injected SessionStart -# hook (below) fires within ~1s carrying Codex's REAL session_id, so it is the -# single authoritative signal. A pre-exec wrapper-fired session-start has no -# session id and made the handler mint a junk `fallback-*` record alongside the -# real one (a phantom duplicate in the GUI), so it is intentionally omitted. +# On a FRESH launch, no wrapper-fired session-start: codex's own injected +# SessionStart hook (below) fires within ~1s carrying codex's REAL session_id, so +# it is the single authoritative signal. A pre-exec wrapper-fired session-start +# there had no session id and minted a junk `fallback-*` record (a phantom in the +# GUI), so it stays omitted for fresh launches. +# +# RESUME is different: codex does NOT fire SessionStart when resuming, so the +# injected hooks alone would never re-bind the session (its only pid-refreshing +# event never arrives), leaving it stuck on the dead pre-relaunch pid -> `.ended` +# -> read-only with no input bar, with no way out (you cannot submit a prompt +# from a GUI that has no composer). The wrapper has the resumed id (from argv) and +# the new live pid ($$, exported as CMUX_CODEX_PID above), so it fires the +# session-start ITSELF. The handler binds surface/workspace/cwd from the cmux env +# and pid from CMUX_CODEX_PID, re-binding the resumed session to its live pid and +# flipping it back to idle/editable. Fire-and-forget (backgrounded + disowned) so +# it never blocks the launch; best-effort so a failure can never break `codex`. +cmux_codex_resume_sid="$(cmux_codex_resume_session_id "$@")" +if [[ -n "$cmux_codex_resume_sid" \ + && -n "$CMUX_CODEX_HOOK_CMUX_BIN" && -x "$CMUX_CODEX_HOOK_CMUX_BIN" ]]; then + cmux_codex_resume_payload="{\"session_id\":\"$cmux_codex_resume_sid\",\"cwd\":\"$PWD\"}" + if [[ -n "${CMUX_SOCKET_PATH:-}" ]]; then + ( printf '%s' "$cmux_codex_resume_payload" \ + | nohup "$CMUX_CODEX_HOOK_CMUX_BIN" --socket "$CMUX_SOCKET_PATH" hooks codex session-start >/dev/null 2>&1 ) & + else + ( printf '%s' "$cmux_codex_resume_payload" \ + | nohup "$CMUX_CODEX_HOOK_CMUX_BIN" hooks codex session-start >/dev/null 2>&1 ) & + fi + disown 2>/dev/null || true +fi # Build the per-invocation [hooks] injection. The cmux CLI emits the exact arg # list (NUL-separated) that enables hooks and injects cmux's FIRE-AND-FORGET hook From 383022ddbe7c3f7010cfc1ab695bff9992721ded Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Tue, 23 Jun 2026 16:23:04 -0700 Subject: [PATCH 19/38] agent-session: re-bind resumed sessions live from cmux's own authority Detection of a resumed agent session was hook-driven: the GUI learned a session was live on a surface only when the agent fired a SessionStart hook. codex fires NO SessionStart on resume, and a subrouter/sr or absolute-path launch bypasses the cmux wrapper, so a resumed session kept its stale pre-relaunch record (dead pid -> exit watcher -> .ended) and showed read-only with no composer. Resume is ALWAYS cmux-initiated, so cmux already holds the (session, surface) pair at restore time. Record it directly instead of waiting for a hook the agent may never send: AgentChatSessionRegistry.noteResumeInitiated binds the surface, flips to .idle, and CLEARS the stale pid (re-arming a watcher on the dead pid would immediately re-end the session); the live pid backfills from the agent's own hooks when it has them. Wired from the session-restore path (Workspace.createPanel) for both the restorable-agent and agent-hook-binding restores. Buffered through a static entry point + flush in start(), because restore can run before the service is wired (a direct call would be a silent no-op). Verified on device: all 9 restored codex sessions fire the re-bind and become .idle/editable on relaunch. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 63 ++++++++++++++ .../AgentChatTranscriptService.swift | 85 +++++++++++++++++++ Sources/Workspace.swift | 35 ++++++++ 3 files changed, 183 insertions(+) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 3c5c299d68ff..61e27e45adde 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -302,6 +302,69 @@ final class AgentChatSessionRegistry { return record } + /// Records, from cmux's own authority, that it is resuming `rawSessionID` + /// onto `surfaceID`. Resume is ALWAYS cmux-initiated, and some agents (codex) + /// fire NO SessionStart hook on resume, so the hook-driven path would keep the + /// stale pre-relaunch record: its pid is already dead, the exit watcher flips + /// it to `.ended`, and the GUI shows it read-only with no composer (and can't + /// recover, since you can't submit a prompt from a hidden composer). cmux + /// holds the `(session, surface)` pair at resume time, so it writes that fact + /// directly instead of waiting for a hook the agent will never send. + /// + /// Clearing the pid is essential: re-stamping the record while it still + /// carries the DEAD pre-relaunch pid would re-arm the exit watcher on that pid + /// and immediately re-end the session. With pid cleared, no watcher arms and + /// the session is shown live/editable; the live pid backfills later from the + /// agent's own hooks (when it has them), which is the safe direction. + func noteResumeInitiated( + sessionID rawSessionID: String, + source: String, + surfaceID: String?, + workspaceID: String?, + workingDirectory: String? + ) { + let sessionID = Self.normalizedSessionID(rawSessionID, source: source) + let now = Date() + cmuxDebugLog( + "agentChat.resumeInitiated session=\(sessionID.prefix(8)) source=\(source) " + + "surface=\((surfaceID ?? "nil").prefix(8)) existed=\(records[sessionID] != nil)" + ) + let normalizedSurface = surfaceID.flatMap { $0.isEmpty ? nil : $0 } + let normalizedWorkspace = workspaceID.flatMap { $0.isEmpty ? nil : $0 } + let normalizedCwd = workingDirectory.flatMap { $0.isEmpty ? nil : $0 } + if records[sessionID] != nil { + update(sessionID: sessionID) { record in + if let normalizedSurface { record.surfaceID = normalizedSurface } + if let normalizedWorkspace { record.workspaceID = normalizedWorkspace } + if let normalizedCwd { record.workingDirectory = normalizedCwd } + record.pid = nil + record.state = .idle + record.lastActivityAt = now + } + return + } + // The seed has not created this record yet (or it was pruned). Create it + // live so the GUI shows the resumed session immediately; the transcript + // path resolves on demand from the session id. + var record = AgentChatSessionRecord( + sessionID: sessionID, + agentKind: ChatAgentKind(source: source), + workspaceID: normalizedWorkspace, + surfaceID: normalizedSurface, + workingDirectory: normalizedCwd, + transcriptPath: nil, + state: .idle, + lastActivityAt: now, + title: nil, + pid: nil + ) + stampVersion(&record) + records[sessionID] = record + syncProcessExitWatch(for: record) + updateLiveSessionIndex(previous: nil, current: record) + onRecordChanged?(record, nil) + } + /// Reads one session's hook-store entry OFF the main actor and applies any /// still-missing bindings on the main actor. The hot path (`noteHookEvent`) /// returns immediately; bindings land a moment later via `update`, which diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 2dedff377ae1..126c5b49ebeb 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -42,10 +42,74 @@ final class AgentChatTranscriptService { } } + /// A `(session, surface)` resume re-bind cmux authored during session + /// restore, buffered until the service is live (restore can run before app + /// setup assigns this service, so a direct call would be a silent no-op). + private struct PendingResumeIntent { + let sessionID: String + let source: String + let surfaceID: String? + let workspaceID: String? + let workingDirectory: String? + } + + /// Resume re-binds recorded before ``start()`` wired the live instance. + private static var pendingResumeIntents: [PendingResumeIntent] = [] + /// The started service, used to apply resume re-binds immediately once live. + private static weak var liveInstance: AgentChatTranscriptService? + + /// Records, from cmux's own authority, that it is resuming `sessionID` onto + /// `surfaceID` (see + /// ``AgentChatSessionRegistry/noteResumeInitiated(sessionID:source:surfaceID:workspaceID:workingDirectory:)``). + /// Static so the restore path need not hold a service reference: before the + /// service starts (restore can run first) the intent is buffered and flushed + /// in ``start()``; after, it applies immediately. + static func recordResumeIntent( + sessionID: String, + source: String, + surfaceID: String?, + workspaceID: String?, + workingDirectory: String? + ) { + if let live = liveInstance { + live.noteResumeInitiated( + sessionID: sessionID, + source: source, + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: workingDirectory + ) + } else { + pendingResumeIntents.append(PendingResumeIntent( + sessionID: sessionID, + source: source, + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: workingDirectory + )) + } + } + /// Seeds the session registry from the on-disk hook stores. Call once /// at app startup. Sessions are tracked only via the reliable hook-event /// path thereafter; cmux does not detect agents that never fire a hook. func start() { + Self.liveInstance = self + // Apply resume re-binds buffered before the service was wired. The seed + // only creates records that don't already exist, so an intent applied + // here is preserved (the seed skips it) and one applied after flips the + // seeded `.ended` record to `.idle`: either order converges. + let buffered = Self.pendingResumeIntents + Self.pendingResumeIntents.removeAll() + for intent in buffered { + registry.noteResumeInitiated( + sessionID: intent.sessionID, + source: intent.source, + surfaceID: intent.surfaceID, + workspaceID: intent.workspaceID, + workingDirectory: intent.workingDirectory + ) + } // Seeding reads+parses the hook-store JSON off the main actor; kick it // off and return. Live hook events also populate the registry, and the // seed converges within milliseconds. @@ -107,6 +171,27 @@ final class AgentChatTranscriptService { await registry.refreshBindingsFromHookStore(sessionID: sessionID) } + /// cmux-authored resume re-bind (see + /// ``AgentChatSessionRegistry/noteResumeInitiated(sessionID:source:surfaceID:workspaceID:workingDirectory:)``). + /// Called from the session-restore path when cmux auto-resumes an agent, so + /// the GUI reflects the live session immediately instead of waiting for a + /// SessionStart hook the agent (codex) does not fire on resume. + func noteResumeInitiated( + sessionID: String, + source: String, + surfaceID: String?, + workspaceID: String?, + workingDirectory: String? + ) { + registry.noteResumeInitiated( + sessionID: sessionID, + source: source, + surfaceID: surfaceID, + workspaceID: workspaceID, + workingDirectory: workingDirectory + ) + } + /// Re-stamps a session's stored workspace id to the workspace its surface /// currently lives in. cmux workspace ids regenerate on every Mac relaunch /// while surface ids are stable, so a session created before the last diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 747a3bb12a0a..e1431a1a92df 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1263,6 +1263,41 @@ extension Workspace { tmuxStartCommand: restoredTmuxStartCommand, hasResumeStartupWork: restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil ) + // cmux is itself resuming this agent session onto the restored surface + // (snapshot.id is the ghostty surface id, i.e. CMUX_SURFACE_ID). Some + // agents (codex) fire NO SessionStart hook on resume, so record the + // (session, surface) binding from cmux's own authority here instead of + // waiting for a hook that will not arrive; otherwise the chat registry + // keeps the stale pre-relaunch record (dead pid -> .ended) and the iOS + // GUI shows it read-only. See AgentChatSessionRegistry.noteResumeInitiated. + // The (session id, agent source) being resumed comes from the + // restorable-agent snapshot when present, else from the agent-hook + // resume binding (most restores carry only the binding, whose + // `checkpointId` IS the agent session id). + let resumeReboundSession: (sessionID: String, source: String)? = { + if let restorableAgent { + return (restorableAgent.sessionId, restorableAgent.kind.rawValue) + } + if let binding = resumeBinding, + binding.isAgentHookBinding, + let checkpoint = binding.checkpointId?.trimmingCharacters(in: .whitespacesAndNewlines), + !checkpoint.isEmpty, + let bindingKind = binding.kind?.trimmingCharacters(in: .whitespacesAndNewlines), + !bindingKind.isEmpty { + return (checkpoint, bindingKind) + } + return nil + }() + if restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil, + let resumeReboundSession { + AgentChatTranscriptService.recordResumeIntent( + sessionID: resumeReboundSession.sessionID, + source: resumeReboundSession.source, + surfaceID: snapshot.id.uuidString, + workspaceID: id.uuidString, + workingDirectory: workingDirectory + ) + } let restoredRemotePTYSessionID: String? = { guard remoteConfiguration?.preserveAfterTerminalExit == true, remoteConfiguration?.persistentDaemonSlot != nil else { From 4ba2fd5d40e4185414af85aa2a1572da50a9321f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 24 Jun 2026 13:46:33 -0700 Subject: [PATCH 20/38] agent-session: harden GUI reliability (reducer clobber, deterministic match, resume re-key) Four correctness fixes from an adversarial review of the iOS coding-agent GUI across Claude + Codex, so the Telegram<->GUI flow (toggle appears, message sends, response live) holds in more cases per the spec. - List reducer ignores the unversioned `stateChanged`: every transition also emits a versioned `descriptorChanged` carrying the same state, so the list is driven solely by the version-gated descriptor path; a reordered/duplicated bare `stateChanged` can no longer regress newer state. The focused conversation's store still consumes `stateChanged` directly. +2 reducer tests. - mobileChatRecordMatchesAgent is now deterministic (spec principle 2): the live send/list gate uses process liveness (kill(pid,0)) instead of terminal-title / screen-scraped agent detection, which could hide a correctly-bound live session. When the pid is unknown (a session re-bound on resume from cmux's own authority, e.g. `sr codex resume` that bypasses the hook shim), trust the durable surface binding rather than invent a negative. - Resume re-bind is keyed on the real `terminalPanel.id` and recorded after the surface is created, fixing the surface-id-collision case (restore-into-live / duplicate-workspace) where a fresh id was minted and the old key bound nothing. - Resume re-bind no longer gated on cmux generating the resume launch, so an auto-resume-off user who resumes manually (`sr codex resume`) gets an editable GUI (.idle) instead of a stuck read-only (.ended) record. Recording .idle is the safe direction per spec (never invent ended). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Store/ChatSessionListReducer.swift | 20 ++++--- .../ChatSessionListReducerTests.swift | 31 ++++++++-- Sources/TerminalController+MobileChat.swift | 59 +++++++------------ Sources/Workspace.swift | 55 ++++++++++------- 4 files changed, 92 insertions(+), 73 deletions(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift index 82605a149368..d1d6cfa98305 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatSessionListReducer.swift @@ -56,15 +56,17 @@ public struct ChatSessionListReducer: Sendable { updated.append(descriptor) } return updated - case .stateChanged(let state): - // A state push carries no workspace; only ever update an entry - // already in the (workspace-scoped) list, never insert. - guard let index = sessions.firstIndex(where: { $0.id == frame.sessionID }) else { - return sessions - } - var updated = sessions - updated[index] = updated[index].withState(state) - return updated + case .stateChanged: + // The bare state push carries NO version, so applying it here would + // let a duplicated or reordered frame clobber newer state the list + // already holds (the host emits an unversioned `stateChanged` AND a + // versioned `descriptorChanged` for the SAME transition, so the list + // always gets the state through the version-gated descriptor path + // above). The list is therefore driven solely by `descriptorChanged`; + // the unversioned `stateChanged` is a no-op for the list. The focused + // conversation's `ChatConversationStore` still consumes `stateChanged` + // directly for its own live state (it is not version-reconciled). + return sessions case .appended, .updated, .terminalBlocks, .reset, .unknown: // Transcript-content frames don't affect the session list. return sessions diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift index 2902f088d55d..fc0a4e4ca18d 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionListReducerTests.swift @@ -60,15 +60,36 @@ struct ChatSessionListReducerTests { #expect(reducer.applying(frame, to: []).map(\.id) == ["s9"]) } - @Test("a stateChanged updates an existing session (ended -> read-only)") - func stateChangedUpdatesExisting() { + @Test("an unversioned stateChanged never mutates the list (descriptorChanged is authoritative)") + func stateChangedIsNoOpForList() { let reducer = ChatSessionListReducer(workspaceID: "ws-1") let seed = [descriptor("s1", state: Self.working)] + // The host pairs every transition with a versioned descriptorChanged, so + // the bare stateChanged must not touch the list (it carries no version + // and would otherwise be a clobber vector). let frame = ChatSessionEventFrame(sessionID: "s1", event: .stateChanged(.ended)) - let result = reducer.applying(frame, to: seed) + #expect(reducer.applying(frame, to: seed) == seed) + } + + @Test("a reordered stateChanged cannot regress newer descriptor state (clobber guard)") + func stateChangedDoesNotClobberNewerDescriptor() { + let reducer = ChatSessionListReducer(workspaceID: "ws-1") + func desc(_ state: ChatAgentState, _ version: Int) -> ChatSessionDescriptor { + ChatSessionDescriptor( + id: "s1", agentKind: .codex, workspaceID: "ws-1", + terminalID: "s1", state: state, version: version + ) + } + // The list has the newest state (ended, v7) from a versioned descriptor. + let seed = [desc(.ended, 7)] + // A late, reordered bare stateChanged(working) arrives. Before this fix + // it overwrote the row back to working with the stale version; now it is + // ignored, so the ended (read-only) state the list authoritatively holds + // survives. + let stale = ChatSessionEventFrame(sessionID: "s1", event: .stateChanged(Self.working)) + let result = reducer.applying(stale, to: seed) #expect(result.first?.state == .ended) - // identity and bindings survive the state-only fold - #expect(result.first?.terminalID == "s1") + #expect(result.first?.version == 7) } @Test("a stateChanged for an unknown session never inserts") diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index ba38a686cba7..8c1fbe3f3773 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -100,7 +100,7 @@ extension TerminalController { for record in service.sessionRecords(workspaceID: nil) { guard let surfaceID = record.surfaceID, let surfaceUUID = UUID(uuidString: surfaceID), - let terminalPanel = workspace.terminalPanel(for: surfaceUUID) else { + workspace.terminalPanel(for: surfaceUUID) != nil else { continue } // A LIVE session must still be the current agent on the terminal, so @@ -111,11 +111,7 @@ extension TerminalController { // fresh pull must not drop it — dropping it is what made the toggle // go stale and vanish on tap after the agent exited. if record.state != .ended, - !mobileChatRecordMatchesAgent( - record: record, - workspace: workspace, - terminalPanel: terminalPanel - ) { + !mobileChatRecordMatchesAgent(record: record) { continue } // Re-stamp stale-workspace records to W so the seed and live pushes @@ -397,42 +393,29 @@ extension TerminalController { requireTerminal: true ), let surfaceId = resolved.surfaceId, - let terminalPanel = resolved.workspace.terminalPanel(for: surfaceId) else { + resolved.workspace.terminalPanel(for: surfaceId) != nil else { return false } - return mobileChatRecordMatchesAgent( - record: record, - workspace: resolved.workspace, - terminalPanel: terminalPanel - ) + return mobileChatRecordMatchesAgent(record: record) } - /// Agent-match core: whether an already-resolved `(workspace, terminalPanel)` - /// still looks like the agent the record represents. Resolution-free so the - /// workspace-filtered listing path can call it with the surface's CURRENT - /// workspace rather than the record's stale stored one. - private func mobileChatRecordMatchesAgent( - record: AgentChatSessionRecord, - workspace: Workspace, - terminalPanel: TerminalPanel - ) -> Bool { - let title = workspace.panelTitle(panelId: terminalPanel.id) ?? terminalPanel.displayTitle - let normalizedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let context = WorkspaceContentView.terminalAgentContext(panel: terminalPanel, workspace: workspace) - switch record.agentKind { - case .claude: - return TextBoxAgentDetection.isClaudeCode(context: context) - || normalizedTitle.contains("claude") - || title.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("✳") - case .codex: - return TextBoxAgentDetection.codex.matches(context: context) - || normalizedTitle.contains("codex") - case .other(let source): - return !source.isEmpty && ( - context.localizedCaseInsensitiveContains(source) - || normalizedTitle.contains(source.lowercased()) - ) - } + /// Agent-match core: whether the record's bound surface (already resolved to + /// a live terminal by the caller) still hosts the agent. + /// + /// Deterministic per the agent-session spec (principle 2): the surface + /// binding is authoritative — NEVER the terminal title or screen-scraped + /// agent detection, which can both hide a correctly-bound live session (a + /// renamed title or a scrolled-off banner) and mis-attribute. The reliable + /// signal is process liveness: when cmux knows the agent pid, a live pid + /// means the agent is still here and a dead pid means it is gone (the + /// process-exit watcher ends it). When the pid is unknown — a session + /// re-bound on resume from cmux's own authority, whose pid is not backfilled + /// until the agent's own hooks arrive (e.g. an `sr codex resume` that + /// bypasses the hook-injecting shim) — trust the durable surface binding + /// rather than inventing a negative that would wrongly hide a live session. + private func mobileChatRecordMatchesAgent(record: AgentChatSessionRecord) -> Bool { + guard let pid = record.pid else { return true } + return kill(pid_t(pid), 0) == 0 || errno == EPERM } private func mobileChatTerminalPanel(sessionID: String) async -> TerminalPanel? { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e1431a1a92df..524e944284ed 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1263,17 +1263,19 @@ extension Workspace { tmuxStartCommand: restoredTmuxStartCommand, hasResumeStartupWork: restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil ) - // cmux is itself resuming this agent session onto the restored surface - // (snapshot.id is the ghostty surface id, i.e. CMUX_SURFACE_ID). Some - // agents (codex) fire NO SessionStart hook on resume, so record the - // (session, surface) binding from cmux's own authority here instead of - // waiting for a hook that will not arrive; otherwise the chat registry - // keeps the stale pre-relaunch record (dead pid -> .ended) and the iOS - // GUI shows it read-only. See AgentChatSessionRegistry.noteResumeInitiated. - // The (session id, agent source) being resumed comes from the - // restorable-agent snapshot when present, else from the agent-hook - // resume binding (most restores carry only the binding, whose - // `checkpointId` IS the agent session id). + // cmux is itself resuming this agent session onto the restored surface. + // Some agents (codex) fire NO SessionStart hook on resume, and an + // `sr codex resume` bypasses the hook-injecting shim entirely, so + // record the (session, surface) binding from cmux's own authority + // instead of waiting for a hook that will not arrive; otherwise the + // chat registry keeps the stale pre-relaunch record (dead pid -> + // .ended) and the iOS GUI shows it read-only. The actual call is made + // AFTER the surface is created, keyed on the real `terminalPanel.id` + // (which differs from `snapshot.id` when a surface-id collision forces + // a fresh id on restore-into-live / duplicate-workspace). The + // (session id, agent source) comes from the restorable-agent snapshot + // when present, else from the agent-hook resume binding (most restores + // carry only the binding, whose `checkpointId` IS the agent session id). let resumeReboundSession: (sessionID: String, source: String)? = { if let restorableAgent { return (restorableAgent.sessionId, restorableAgent.kind.rawValue) @@ -1288,16 +1290,6 @@ extension Workspace { } return nil }() - if restoredBindingLaunch != nil || restoredAgentResumeLaunch != nil, - let resumeReboundSession { - AgentChatTranscriptService.recordResumeIntent( - sessionID: resumeReboundSession.sessionID, - source: resumeReboundSession.source, - surfaceID: snapshot.id.uuidString, - workspaceID: id.uuidString, - workingDirectory: workingDirectory - ) - } let restoredRemotePTYSessionID: String? = { guard remoteConfiguration?.preserveAfterTerminalExit == true, remoteConfiguration?.persistentDaemonSlot != nil else { @@ -1396,6 +1388,27 @@ extension Workspace { ) else { return nil } + // Re-bind the resumed agent session from cmux's own authority, keyed + // on the surface that was actually created. `terminalPanel.id` equals + // `snapshot.id` on the normal path, but on a surface-id collision + // (restore-into-live / duplicate-workspace) `newTerminalSurface` + // minted a fresh id, so keying on `snapshot.id` would bind to a + // surface that does not exist and the GUI would never find the + // session. This is unconditional on whether cmux runs the resume + // command itself: a restored surface that CARRIES a resumable agent + // binding must flip its registry record to live/.idle so the iOS GUI + // is editable, even when auto-resume is off and the user resumes + // manually (e.g. `sr codex resume`). Recording .idle here is the safe + // direction per the spec — never invent `ended`. + if let resumeReboundSession { + AgentChatTranscriptService.recordResumeIntent( + sessionID: resumeReboundSession.sessionID, + source: resumeReboundSession.source, + surfaceID: terminalPanel.id.uuidString, + workspaceID: id.uuidString, + workingDirectory: workingDirectory + ) + } if let restoredRemotePTYSessionID { registerRemoteRelayIDAliases( remotePTYSessionID: restoredRemotePTYSessionID, From dfa0365542b72395c8b3f2bcc5adbbb1c3382c16 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 24 Jun 2026 14:58:18 -0700 Subject: [PATCH 21/38] mobile chat: accurate transcript-not-found message (home-dir case) The old "isn't readable on the Mac yet. Send the agent a prompt, then retry." was misleading when the agent runs under a git-rooted home directory: Claude Code does not persist a project transcript when the session's git root is $HOME, so retrying never produces a transcript. New copy covers both the just-started timing case (send a prompt + Retry) and the structural case (home directory keeps no transcript -> use the Terminal tab). en + ja updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- Resources/Localizable.xcstrings | 4 ++-- Sources/TerminalController+MobileChat.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 29e6c669accd..91cf8e21b05a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -210167,13 +210167,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This conversation's transcript isn't readable on the Mac yet. Send the agent a prompt from its terminal, then retry." + "value": "The Mac can't find a transcript file for this conversation. If the agent just started in a project folder, send it a prompt and tap Retry. If it's running in your home directory, it doesn't keep a transcript, so use the Terminal tab to interact." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この会話のトランスクリプトはまだMacで読み取れません。ターミナルからエージェントにプロンプトを送ってから、再試行してください。" + "value": "この会話のトランスクリプトファイルがMacで見つかりません。エージェントをプロジェクトフォルダで起動したばかりなら、プロンプトを送って「再試行」をタップしてください。ホームディレクトリで実行している場合はトランスクリプトが保存されないため、ターミナルタブで操作してください。" } } } diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 8c1fbe3f3773..13a4586a7662 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -188,7 +188,7 @@ extension TerminalController { #endif return .err(code: "not_found", message: String( localized: "mobile.chat.error.transcriptNotReadable", - defaultValue: "This conversation's transcript isn't readable on the Mac yet. Send the agent a prompt from its terminal, then retry." + defaultValue: "The Mac can't find a transcript file for this conversation. If the agent just started in a project folder, send it a prompt and tap Retry. If it's running in your home directory, it doesn't keep a transcript, so use the Terminal tab to interact." ), data: [ "session_id": sessionID ]) From 79636d03e2971acb9c7393d130e184ad84a45dbc Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 24 Jun 2026 16:01:34 -0700 Subject: [PATCH 22/38] agent-session: honor CLAUDE_CONFIG_DIR / CODEX_HOME in transcript fallback The transcript resolver's derived-path fallback hardcoded ~/.claude and ~/.codex, so a user who relocates their agent config dir (CLAUDE_CONFIG_DIR for Claude, CODEX_HOME for Codex, e.g. via a launcher/subrouter) would have fallback-resolved transcripts (notably codex resumed sessions, resolved by scanning the sessions dir) come up empty even though the files exist. Resolve the config-dir root from the env override (expanding a leading ~), defaulting to ~/.claude / ~/.codex. The PRIMARY source is unchanged: the hook-recorded absolute transcriptPath already encodes any custom dir; this only hardens the fallback used when no path was recorded. environment is injectable for tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChatTranscriptResolver.swift | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift index 601715a6346e..b54a0ab5dc57 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptResolver.swift @@ -8,12 +8,48 @@ import Foundation /// codex: rollout filename containing the session id). struct AgentChatTranscriptResolver: Sendable { private let homeDirectory: URL + /// Config-dir root for Claude (`$CLAUDE_CONFIG_DIR` or `~/.claude`). + private let claudeConfigRoot: URL + /// Config-dir root for Codex (`$CODEX_HOME` or `~/.codex`). + private let codexConfigRoot: URL /// Creates a resolver. /// - /// - Parameter homeDirectory: Injectable home directory for tests. - init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + /// The derived-path fallbacks honor the agents' own config-dir env + /// overrides so a user who relocates their config (e.g. `CLAUDE_CONFIG_DIR` + /// or `CODEX_HOME`, including via a launcher/subrouter) still has transcripts + /// resolved. The PRIMARY source remains the hook-recorded absolute + /// `transcriptPath`, which already encodes any custom dir; this only fixes + /// the fallback used when no path was recorded (e.g. a codex session resumed + /// out-of-band, resolved by scanning the sessions dir). + /// + /// - Parameters: + /// - homeDirectory: Injectable home directory for tests. + /// - environment: Injectable environment for tests; defaults to the + /// process environment. Empty/whitespace override values are ignored. + init( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment + ) { self.homeDirectory = homeDirectory + self.claudeConfigRoot = Self.configRoot( + override: environment["CLAUDE_CONFIG_DIR"], + default: homeDirectory.appendingPathComponent(".claude", isDirectory: true) + ) + self.codexConfigRoot = Self.configRoot( + override: environment["CODEX_HOME"], + default: homeDirectory.appendingPathComponent(".codex", isDirectory: true) + ) + } + + /// Resolves a config-dir root from an env override, expanding a leading `~`, + /// falling back to `defaultRoot` when the override is absent or blank. + private static func configRoot(override: String?, default defaultRoot: URL) -> URL { + guard let trimmed = override?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return defaultRoot + } + return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath, isDirectory: true) } /// Resolves the transcript path for a session. @@ -43,8 +79,7 @@ struct AgentChatTranscriptResolver: Sendable { let fileManager = FileManager.default guard let cwd = record.workingDirectory else { return nil } let projectDir = RestorableAgentSessionIndex.encodeClaudeProjectDir(cwd) - let path = homeDirectory - .appendingPathComponent(".claude", isDirectory: true) + let path = claudeConfigRoot .appendingPathComponent("projects", isDirectory: true) .appendingPathComponent(projectDir, isDirectory: true) .appendingPathComponent("\(record.sessionID).jsonl", isDirectory: false) @@ -57,8 +92,7 @@ struct AgentChatTranscriptResolver: Sendable { /// the session id. private func codexFallbackPath(sessionID: String) -> String? { let fileManager = FileManager.default - let root = homeDirectory - .appendingPathComponent(".codex", isDirectory: true) + let root = codexConfigRoot .appendingPathComponent("sessions", isDirectory: true) guard let enumerator = fileManager.enumerator( at: root, From 7d5b8b1db157dd7ec15bd1614458e565a9d11adf Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 16:38:05 -0700 Subject: [PATCH 23/38] agent-session: tree-aware end backstop (observe-floor liveness) A session's liveness was judged from a single recorded pid. With any launcher indirection (a subrouter like `sr`, a `node` shim), that pid is the launcher, not the agent (the real codex/claude binary is deeper in the process tree). So when the launcher or an intermediate exited, cmux wrongly marked a live session `.ended` (GUI shows no input bar). Now, before ending, verify against the surface's process tree off-main: if a real agent process matching the session's kind still exists anywhere under the surface, re-bind the record's pid to it (re-arming the exit watcher on the real agent) instead of ending. Only end when no agent remains in the tree. The synchronous dead-pid check in liveSession() defers to the same tree-aware path and keeps showing the session meanwhile (never hides a live agent). Reuses the existing CmuxTopProcessSnapshot + CmuxTaskManagerCodingAgentDefinition classifier; the tree walk runs off-main only at the rare exit-decision moment, never on the typing path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 76 +++++++++++++++++-- 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 61e27e45adde..37773a534177 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -95,16 +95,72 @@ final class AgentChatSessionRegistry { source.resume() } - /// Flips a session to `.ended` because its agent process exited. Ignores a - /// stale fire: the session may have resumed under a new pid (`claude - /// --resume`), and the predecessor's exit must not end the live session. - /// `ended` is retained (the GUI stays shown, the input bar disables); only - /// the watcher is torn down. + /// Observe-floor liveness: the pid of a live agent process matching `kind` + /// anywhere under `surfaceID`'s process tree, or nil if none. + /// + /// A launcher or intermediate process (a subrouter like `sr`, a `node` + /// shim) is NOT the agent; the real agent binary (e.g. `codex`, `claude`) + /// appears deeper in the tree. So liveness must be judged from the whole + /// process tree under the surface, never from a single recorded pid that may + /// be a launcher. Nonisolated and snapshot-based so it runs off the main + /// actor; callers hop back to the main actor to apply the result. The + /// classifier matches by process basename, so only the real agent binary + /// matches (a `node …/codex` shim is named `node` and does not). + private nonisolated static func liveAgentPID(surfaceID: String, kind: ChatAgentKind) -> Int? { + guard let surfaceUUID = UUID(uuidString: surfaceID) else { return nil } + let snapshot = CmuxTopProcessSnapshot.capture( + includeProcessDetails: true, + includeCMUXScope: true + ) + let rootPIDs = snapshot.pids(forCMUXSurfaceID: surfaceUUID) + guard !rootPIDs.isEmpty else { return nil } + let wantedID = kind.sourceName + for pid in snapshot.expandedPIDs(rootPIDs: rootPIDs).sorted() { + guard let info = snapshot.process(pid: pid), + let def = CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: info.name, + processPath: info.path, + arguments: [], + environment: [:] + ), + def.id == wantedID else { continue } + return pid + } + return nil + } + + /// The watched agent process exited. Before ending the session, verify + /// against the surface's process tree off-main: the dead pid may be a + /// launcher/intermediate (subrouter, `node` shim) while the real agent still + /// runs, in which case re-bind to the live agent pid instead of ending. + /// Ignores a stale fire (the session may have resumed under a new pid; + /// `claude --resume`). `ended` is retained (the GUI stays shown, the input + /// bar disables); only the watcher is torn down. private func handleProcessExit(sessionID: String, pid: Int) { guard let record = records[sessionID], record.pid == pid, record.state != .ended else { return } - update(sessionID: sessionID) { $0.state = .ended } + guard let surfaceID = record.surfaceID else { + update(sessionID: sessionID) { $0.state = .ended } + return + } + let kind = record.agentKind + Task.detached { [weak self] in + let livePID = Self.liveAgentPID(surfaceID: surfaceID, kind: kind) + await MainActor.run { + guard let self, + let current = self.records[sessionID], + current.pid == pid, + current.state != .ended else { return } + if let livePID, livePID != pid { + // Real agent still alive under the surface: re-bind to it + // (this re-arms the exit watcher on the real agent pid). + self.update(sessionID: sessionID) { $0.pid = livePID } + } else { + self.update(sessionID: sessionID) { $0.state = .ended } + } + } + } } /// One session's record. @@ -128,8 +184,12 @@ final class AgentChatSessionRegistry { return nil } if let pid = record.pid, processIsDead(pid) { - update(sessionID: sessionID) { $0.state = .ended } - continue + // The recorded pid is dead, but it may be a launcher while the + // real agent still runs under the surface (subrouter / shim). + // Defer to the tree-aware check (re-bind or end off-main); keep + // showing the session for now so a live agent is never hidden. + handleProcessExit(sessionID: sessionID, pid: pid) + return record } return record } From 80955a0f10631e66356fa1247fd0b8ea771ad092 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 16:49:57 -0700 Subject: [PATCH 24/38] settings: add Codex wrapper integration toggle (mirrors Claude) Codex hook injection was always-on (gated only by the CMUX_CODEX_HOOKS_DISABLED env opt-out, no UI). Add a first-class "Codex Integration" toggle in Automation settings, mirroring "Claude Code Integration": - New catalog key integrations.codex.hooksEnabled (default true), threaded through AgentIntegrationSettingsReading/Store and TerminalSurfaceSpawnPolicy. - When off, the spawn path exports CMUX_CODEX_HOOKS_DISABLED=1; the codex wrapper already no-ops on that env (shim stays on PATH, harmless), so resumed codex still routes through the shim but injects no hooks. - Settings UI codexCard + en/ja strings. The note states cmux still tracks live Codex sessions it can observe even when the toggle is off (the observe floor), so disabling it never blinds the GUI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Keys/IntegrationsCatalogSection.swift | 6 ++ .../AgentIntegrationSettingsReading.swift | 3 + .../AgentIntegrationSettingsStore.swift | 4 ++ .../Sections/AutomationSection.swift | 25 ++++++- .../Spawn/TerminalSurfaceSpawnPolicy.swift | 6 ++ ...rminalSurface+RuntimeSurfaceCreation.swift | 7 ++ Resources/Localizable.xcstrings | 68 +++++++++++++++++++ Sources/TerminalSurfaceRuntimeWiring.swift | 1 + 8 files changed, 119 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift index 5bbfafd7d778..c4fffc72161c 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/IntegrationsCatalogSection.swift @@ -20,6 +20,12 @@ public struct IntegrationsCatalogSection: SettingCatalogSection { userDefaultsKey: "claudeCodeCustomClaudePath" ) + public let codexHooksEnabled = DefaultsKey( + id: "integrations.codex.hooksEnabled", + defaultValue: true, + userDefaultsKey: "codexHooksEnabled" + ) + public let ampHooksEnabled = DefaultsKey( id: "integrations.amp.hooksEnabled", defaultValue: true, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsReading.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsReading.swift index 0763b2d4ca02..d618b0286bcf 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsReading.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsReading.swift @@ -9,6 +9,9 @@ public protocol AgentIntegrationSettingsReading: Sendable { /// Whether the Claude Code hooks integration is enabled. var claudeCodeHooksEnabled: Bool { get } + /// Whether the Codex hooks integration (the `codex` wrapper) is enabled. + var codexHooksEnabled: Bool { get } + /// The user-configured `claude` executable path, or `nil` to resolve /// `claude` from `PATH`. Whitespace-only values read as `nil`. var customClaudePath: String? { get } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsStore.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsStore.swift index 3209c8944e46..f36ca81f6d3a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsStore.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/AgentIntegrationSettingsStore.swift @@ -28,6 +28,10 @@ public struct AgentIntegrationSettingsStore: AgentIntegrationSettingsReading { keys.claudeCodeHooksEnabled.value(in: defaults) } + public var codexHooksEnabled: Bool { + keys.codexHooksEnabled.value(in: defaults) + } + public var customClaudePath: String? { let value = keys.claudeCodeCustomClaudePath.value(in: defaults) .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift index 8394e510c86e..e7feb1dd5304 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -15,6 +15,7 @@ public struct AutomationSection: View { @State private var socketPasswordModel: SecretValueModel @State private var modeModel: DefaultsValueModel @State private var claudeCodeModel: DefaultsValueModel + @State private var codexModel: DefaultsValueModel @State private var claudePathModel: DefaultsValueModel @State private var autoNamingModel: DefaultsValueModel @State private var autoNamingAgentModel: DefaultsValueModel @@ -54,6 +55,7 @@ public struct AutomationSection: View { )) _modeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.socketControlMode)) _claudeCodeModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.claudeCodeHooksEnabled)) + _codexModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.codexHooksEnabled)) _claudePathModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.integrations.claudeCodeCustomClaudePath)) _autoNamingModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.workspaceAutoNaming)) _autoNamingAgentModel = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.automation.autoNamingAgent)) @@ -86,6 +88,7 @@ public struct AutomationSection: View { socketControlCard claudeCodeCard + codexCard claudePathCard autoNamingCard ripgrepPathCard @@ -123,7 +126,7 @@ public struct AutomationSection: View { localized: "settings.automation.openAccess.dialog.message", defaultValue: "This disables ancestry and password checks and opens the socket to all local users. Only enable when you understand the risk." )) - }.task { startSettingsObservation([socketPasswordModel, modeModel, claudeCodeModel, claudePathModel, autoNamingModel, autoNamingAgentModel, autoNamingStatusModel, ripgrepPathModel, suppressSubagentModel, ampModel, cursorModel, geminiModel, kiroModel, kiroLevelModel, portBaseModel, portRangeModel]) } + }.task { startSettingsObservation([socketPasswordModel, modeModel, claudeCodeModel, codexModel, claudePathModel, autoNamingModel, autoNamingAgentModel, autoNamingStatusModel, ripgrepPathModel, suppressSubagentModel, ampModel, cursorModel, geminiModel, kiroModel, kiroLevelModel, portBaseModel, portRangeModel]) } } @ViewBuilder @@ -242,6 +245,26 @@ public struct AutomationSection: View { } } + @ViewBuilder + private var codexCard: some View { + SettingsCard { + SettingsCardRow( + configurationReview: .json("automation.codexIntegration"), + String(localized: "settings.automation.codex", defaultValue: "Codex Integration"), + subtitle: codexModel.current + ? String(localized: "settings.automation.codex.subtitleOn", defaultValue: "Sidebar shows Codex session status and notifications.") + : String(localized: "settings.automation.codex.subtitleOff", defaultValue: "Codex runs without cmux integration.") + ) { + Toggle("", isOn: Binding(get: { codexModel.current }, set: { codexModel.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsCodexHooksToggle") + } + SettingsCardDivider() + SettingsCardNote(String(localized: "settings.automation.codex.note", defaultValue: "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off.")) + } + } + @ViewBuilder private var claudePathCard: some View { SettingsCard { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swift index e0430df0ad71..2df41f8c3fe0 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Spawn/TerminalSurfaceSpawnPolicy.swift @@ -9,6 +9,10 @@ public struct TerminalSurfaceSpawnPolicy: Sendable { /// Whether Claude Code hooks are enabled (`CMUX_CLAUDE_HOOKS_DISABLED`). public var claudeHooksEnabled: Bool + /// Whether Codex hooks (the `codex` wrapper) are enabled + /// (`CMUX_CODEX_HOOKS_DISABLED`). + public var codexHooksEnabled: Bool + /// The user's custom `claude` executable path /// (`CMUX_CUSTOM_CLAUDE_PATH`), if set. public var customClaudePath: String? @@ -50,6 +54,7 @@ public struct TerminalSurfaceSpawnPolicy: Sendable { /// Creates a spawn policy snapshot. public init( claudeHooksEnabled: Bool, + codexHooksEnabled: Bool = true, customClaudePath: String?, subagentNotificationEnvironmentKey: String, suppressSubagentNotifications: Bool, @@ -63,6 +68,7 @@ public struct TerminalSurfaceSpawnPolicy: Sendable { showPullRequestsEnabled: Bool ) { self.claudeHooksEnabled = claudeHooksEnabled + self.codexHooksEnabled = codexHooksEnabled self.customClaudePath = customClaudePath self.subagentNotificationEnvironmentKey = subagentNotificationEnvironmentKey self.suppressSubagentNotifications = suppressSubagentNotifications diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index bef838fcf33f..ad4d63d4ab96 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -114,6 +114,13 @@ extension TerminalSurface { if !claudeHooksEnabled { setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } + // The codex wrapper shim is still installed (it stays on PATH so a + // resumed codex routes through it), but when the Codex integration is + // off the wrapper no-ops on this env var and injects no hooks, mirroring + // the Claude toggle. + if !spawnPolicy.codexHooksEnabled { + setManagedEnvironmentValue("CMUX_CODEX_HOOKS_DISABLED", "1") + } if let customClaudePath = spawnPolicy.customClaudePath { setManagedEnvironmentValue("CMUX_CUSTOM_CLAUDE_PATH", customClaudePath) } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 91cf8e21b05a..4921b307045f 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -102387,6 +102387,74 @@ } } }, + "settings.automation.codex": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex Integration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex連携" + } + } + } + }, + "settings.automation.codex.subtitleOn": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar shows Codex session status and notifications." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにCodexセッションの状態と通知が表示されます。" + } + } + } + }, + "settings.automation.codex.subtitleOff": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex runs without cmux integration." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codexはcmux連携なしで実行されます。" + } + } + } + }, + "settings.automation.codex.note": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効にすると、cmuxはcodexコマンドをラップしてセッション追跡と通知フックを注入します。Codexのフックを自分で管理したい場合は無効にしてください。無効でも、cmuxは観測できるライブCodexセッションを追跡します。" + } + } + } + }, "settings.automation.claudeCode": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalSurfaceRuntimeWiring.swift b/Sources/TerminalSurfaceRuntimeWiring.swift index d60a05322b22..d0f7f4599d66 100644 --- a/Sources/TerminalSurfaceRuntimeWiring.swift +++ b/Sources/TerminalSurfaceRuntimeWiring.swift @@ -44,6 +44,7 @@ final class TerminalSurfaceSpawnPolicyBridge: TerminalSurfaceSpawnPolicyProvidin let integrations = AgentIntegrationSettingsStore(defaults: .standard) return TerminalSurfaceSpawnPolicy( claudeHooksEnabled: integrations.claudeCodeHooksEnabled, + codexHooksEnabled: integrations.codexHooksEnabled, customClaudePath: integrations.customClaudePath, subagentNotificationEnvironmentKey: AgentIntegrationSettingsStore.subagentSuppressionEnvironmentKey, suppressSubagentNotifications: integrations.suppressesSubagentNotifications, From 972e1c9f900b7c50f63a2ec5e042ec5d4e709b76 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 16:54:20 -0700 Subject: [PATCH 25/38] codex hooks: emit a #!/bin/sh script-file command, not an inline snippet The wrapper injected each codex hook as an inline shell-snippet `command` string. Normal codex runs that through a shell, but some codex-compatible runtimes (subrouters/proxies) exec the `command` string directly as a program, so the snippet failed with "No such file or directory (os error 2)" and the session was shown inline as a failed hook (and could lose state tracking). emitCodexWrapperInjectArgs now writes each event's body to a #!/bin/sh script in a cmux-owned dir (~/.cmux/hooks, NOT the user's ~/.codex), idempotently + executable, and emits the bare script PATH as the hook command. A file path execs correctly whether the runtime runs it directly or via a shell, so normal codex is unaffected and subrouter runtimes stop erroring. Any write failure falls back to the inline snippet, so the working path can never regress. Verified: emitted SessionStart command is now the script path, and direct-exec of the script (the os-error-2 path) returns `{}` exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/CMUXCLI+CodexFireAndForgetHooks.swift | 71 +++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index c1e0c61482c9..f437edaa3d66 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -29,19 +29,39 @@ extension CMUXCLI { 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 (the command never has one). - guard !ff.contains("'''") else { - throw CLIError(message: "Codex fire-and-forget hook command contains a triple single quote and cannot be TOML-encoded.") + // 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='''\(ff)''',timeout=\(event.timeoutMs)}]}]" + let toml = "hooks.\(event.agentEvent)=[{hooks=[{type=\"command\",command='''\(command)''',timeout=\(event.timeoutMs)}]}]" args.append("-c") args.append(toml) } @@ -57,6 +77,49 @@ extension CMUXCLI { 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\"" From 14840eed20c3611af2f1e123a0faf3580b91beb1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 17:46:35 -0700 Subject: [PATCH 26/38] agent-session: observe-floor detection of untracked agents (process tree) Slice 2 of the reliable-tracking system: discover live codex/claude sessions by observing the process table, with no dependency on hooks firing, so a session launched through any indirection (a subrouter, a wrapper) that fired no hook is still found and bound. On the iOS list pull, a throttled off-main scan walks every cmux-scoped process, matches the real agent binary via the existing coding-agent classifier (deep in an sr -> node -> codex tree the codex binary still matches by basename), and resolves identity without hooks: codex via the rollout .jsonl it holds open (new libproc PROC_PIDLISTFDS/PROC_PIDFDVNODEPATHINFO reader, which also yields the transcript path), claude via --session-id/--resume in argv. Untracked sessions get an .idle presence record that pushes itself to subscribers via onRecordChanged; existing records only get missing bindings backfilled, never a state downgrade. Fire-and-forget so it never blocks the list pull. No config touched, no consent needed (pure observation). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 180 ++++++++++++++++++ .../AgentChatTranscriptService.swift | 8 + Sources/TerminalController+MobileChat.swift | 6 + 3 files changed, 194 insertions(+) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 37773a534177..15a6499ca331 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -2,6 +2,19 @@ import CMUXAgentLaunch import CmuxAgentChat import Foundation +/// A coding-agent session discovered by observing the process table, with no +/// dependency on hooks firing. Identity (and, for codex, the transcript path) +/// comes from the agent's own argv or open transcript file, so a session +/// launched through any indirection (a subrouter, a wrapper) is still found. +nonisolated struct ObservedAgentSession: Sendable { + let sessionID: String + let agentKind: ChatAgentKind + let surfaceID: String + let workspaceID: String? + let pid: Int + let transcriptPath: String? +} + /// Main-actor registry of chat-capable agent sessions, built from agent /// hook events and the on-disk hook session stores. @MainActor @@ -129,6 +142,173 @@ final class AgentChatSessionRegistry { return nil } + // MARK: Observe-floor detection (process tree) + + /// Off-main scan + main-actor apply: discover live codex/claude sessions by + /// observing the process table, with no dependency on hooks firing. Resolves + /// identity from the agent's own state (codex: the rollout file it holds + /// open; claude: its `--session-id`/`--resume` argv), so a session launched + /// through any indirection (a subrouter, a wrapper) is still found and bound. + /// Throttled; safe to call coarsely (e.g. on the iOS list pull). The snapshot + /// is captured off the main actor. + private var observeThrottle: Date? + func observeAgentProcesses() async { + let now = Date() + if let last = observeThrottle, now.timeIntervalSince(last) < 2.0 { return } + observeThrottle = now + let observed = await Task.detached { Self.scanObservedAgentSessions() }.value + applyObservedSessions(observed) + } + + /// Folds detections in: create a record for any session not already known + /// (state `.idle`, from cmux's own observation), and backfill a missing + /// binding (surface / workspace / transcript / pid) on an existing one. + /// Observation only ADDS presence and bindings; it never downgrades + /// hook-derived state. + private func applyObservedSessions(_ observed: [ObservedAgentSession]) { + let now = Date() + for session in observed { + if records[session.sessionID] == nil { + var record = AgentChatSessionRecord( + sessionID: session.sessionID, + agentKind: session.agentKind, + workspaceID: session.workspaceID, + surfaceID: session.surfaceID, + workingDirectory: nil, + transcriptPath: session.transcriptPath, + state: .idle, + lastActivityAt: now, + title: nil, + pid: session.pid + ) + stampVersion(&record) + records[session.sessionID] = record + syncProcessExitWatch(for: record) + updateLiveSessionIndex(previous: nil, current: record) + onRecordChanged?(record, nil) + } else { + update(sessionID: session.sessionID) { rec in + if rec.surfaceID == nil { rec.surfaceID = session.surfaceID } + if rec.workspaceID == nil { rec.workspaceID = session.workspaceID } + if rec.transcriptPath == nil { rec.transcriptPath = session.transcriptPath } + if rec.pid == nil { rec.pid = session.pid } + } + } + } + } + + /// Off-main: one entry per distinct live codex/claude session under any cmux + /// surface, identity resolved without hooks. + private nonisolated static func scanObservedAgentSessions() -> [ObservedAgentSession] { + let snapshot = CmuxTopProcessSnapshot.capture( + includeProcessDetails: true, + includeCMUXScope: true + ) + var result: [ObservedAgentSession] = [] + var seen = Set() + for process in snapshot.cmuxScopedProcesses() { + guard let surfaceID = process.cmuxSurfaceID, + let def = CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: process.name, + processPath: process.path, + arguments: [], + environment: [:] + ), + def.id == "codex" || def.id == "claude" else { continue } + var sessionID: String? + var transcriptPath: String? + if def.id == "codex", let rollout = openCodexRolloutPath(pid: process.pid) { + transcriptPath = rollout + sessionID = firstUUIDLike(in: (rollout as NSString).lastPathComponent) + } + if sessionID == nil, + let argv = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process.pid)?.arguments { + sessionID = sessionIDFromArguments(argv) + } + guard let resolved = sessionID, !seen.contains(resolved) else { continue } + seen.insert(resolved) + result.append(ObservedAgentSession( + sessionID: resolved, + agentKind: ChatAgentKind(source: def.id), + surfaceID: surfaceID.uuidString, + workspaceID: process.cmuxWorkspaceID?.uuidString, + pid: process.pid, + transcriptPath: transcriptPath + )) + } + return result + } + + /// libproc: the path of a `~/.codex/sessions/**/rollout-*.jsonl` the process + /// holds open (codex keeps its rollout open for writing), or nil. + private nonisolated static func openCodexRolloutPath(pid: Int) -> String? { + let listSize = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, nil, 0) + guard listSize > 0 else { return nil } + let count = Int(listSize) / MemoryLayout.stride + guard count > 0 else { return nil } + var fds = [proc_fdinfo](repeating: proc_fdinfo(), count: count) + let used = proc_pidinfo(pid_t(pid), PROC_PIDLISTFDS, 0, &fds, listSize) + guard used > 0 else { return nil } + let actual = Int(used) / MemoryLayout.stride + for index in 0...size) + ) + guard size > 0 else { continue } + let path = withUnsafeBytes(of: &info.pvip.vip_path) { raw -> String in + guard let base = raw.baseAddress else { return "" } + return String(cString: base.assumingMemoryBound(to: CChar.self)) + } + if path.hasSuffix(".jsonl"), path.contains("/.codex/sessions/") { + return path + } + } + return nil + } + + /// Extracts a session id from an agent's argv (`--session-id `, + /// `--session-id=`, `--resume `, `--resume=`). + private nonisolated static func sessionIDFromArguments(_ arguments: [String]) -> String? { + var index = 0 + while index < arguments.count { + let arg = arguments[index] + if arg == "--session-id" || arg == "--resume", index + 1 < arguments.count, + let id = firstUUIDLike(in: arguments[index + 1]) { + return id + } + if arg.hasPrefix("--session-id="), + let id = firstUUIDLike(in: String(arg.dropFirst("--session-id=".count))) { + return id + } + if arg.hasPrefix("--resume="), + let id = firstUUIDLike(in: String(arg.dropFirst("--resume=".count))) { + return id + } + index += 1 + } + return nil + } + + private nonisolated static let uuidLikeRegex = try? NSRegularExpression( + pattern: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ) + + /// The first UUID-shaped substring (matches both standard UUIDs and codex's + /// UUIDv7 rollout ids), or nil. + private nonisolated static func firstUUIDLike(in string: String) -> String? { + guard let regex = uuidLikeRegex else { return nil } + let range = NSRange(string.startIndex..., in: string) + guard let match = regex.firstMatch(in: string, options: [], range: range), + let matchRange = Range(match.range, in: string) else { return nil } + return String(string[matchRange]) + } + /// The watched agent process exited. Before ending the session, verify /// against the surface's process tree off-main: the dead pid may be a /// launcher/intermediate (subrouter, `node` shim) while the real agent still diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index 126c5b49ebeb..bcde02dc53c7 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -155,6 +155,14 @@ final class AgentChatTranscriptService { registry.sessions(workspaceID: workspaceID) } + /// Observe-floor detection: discover live codex/claude sessions from the + /// process table (no hooks required) and fold them into the registry. + /// Throttled; intended to run on the iOS list pull so a fresh detection + /// appears the moment the GUI asks for the list. + func observeAgentProcesses() async { + await registry.observeAgentProcesses() + } + /// The registry record for a session (send path needs the terminal /// binding). /// diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 13a4586a7662..50cf92be3f11 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -78,6 +78,12 @@ extension TerminalController { guard let service = agentChatTranscriptService else { return .err(code: "unavailable", message: Self.chatServiceUnavailableErrorMessage, data: nil) } + // Observe-floor detection: kick a throttled, off-main process-table scan + // so a live codex/claude launched through any indirection (a subrouter, a + // wrapper) that fired no hook is still discovered. Fire-and-forget: a new + // detection creates a record that pushes itself to subscribers via + // onRecordChanged, so it lands without blocking this list pull. + Task { await service.observeAgentProcesses() } guard let workspaceID else { // No filter: return all current-agent sessions across workspaces, // resolving each via its stored binding as before. From f054e794f86316519aaef240b409b67b38a5ea71 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 17:49:29 -0700 Subject: [PATCH 27/38] settings: document consented global Codex hook install (Layer 2) Slice 4: the visible, consented, never-silent global-install option. The Codex Integration card now states that to also track Codex launched through a custom launcher that bypasses the wrapper (e.g. a subrouter), the user runs `cmux hooks setup --agent codex`, which installs hooks into ~/.codex/hooks.json (stating exactly what is written, where). This matches cmux's established consent pattern for amp/cursor/gemini global hooks, and pairs with the observe floor (slice 2): a user who installs nothing still gets presence/liveness/ transcript tracking; the global install only adds richer hook state on wrapper-bypassing launchers. en/ja updated. A one-click installer button over the existing `cmux hooks setup` CLI is a follow-up refinement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/CmuxSettingsUI/Sections/AutomationSection.swift | 2 +- Resources/Localizable.xcstrings | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift index e7feb1dd5304..ef27ba3d17d7 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift @@ -261,7 +261,7 @@ public struct AutomationSection: View { .accessibilityIdentifier("SettingsCodexHooksToggle") } SettingsCardDivider() - SettingsCardNote(String(localized: "settings.automation.codex.note", defaultValue: "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off.")) + SettingsCardNote(String(localized: "settings.automation.codex.note", defaultValue: "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off. To also track Codex launched through a custom launcher that bypasses the wrapper (e.g. a subrouter), run `cmux hooks setup --agent codex`, which installs hooks into ~/.codex/hooks.json.")) } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4921b307045f..4104b76220e1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -102444,13 +102444,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off." + "value": "When enabled, cmux wraps the codex command to inject session tracking and notification hooks. Disable if you prefer to manage Codex hooks yourself. cmux still tracks live Codex sessions it can observe even when this is off. To also track Codex launched through a custom launcher that bypasses the wrapper (e.g. a subrouter), run `cmux hooks setup --agent codex`, which installs hooks into ~/.codex/hooks.json." } }, "ja": { "stringUnit": { "state": "translated", - "value": "有効にすると、cmuxはcodexコマンドをラップしてセッション追跡と通知フックを注入します。Codexのフックを自分で管理したい場合は無効にしてください。無効でも、cmuxは観測できるライブCodexセッションを追跡します。" + "value": "有効にすると、cmuxはcodexコマンドをラップしてセッション追跡と通知フックを注入します。Codexのフックを自分で管理したい場合は無効にしてください。無効でも、cmuxは観測できるライブCodexセッションを追跡します。ラッパーをバイパスするカスタムランチャー(例: サブルーター)経由で起動したCodexも追跡するには、`cmux hooks setup --agent codex` を実行してください。これは ~/.codex/hooks.json にフックをインストールします。" } } } From 19e9509f633aca0994bd015420ee791d6bece6f1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 20:28:42 -0700 Subject: [PATCH 28/38] agent-session: structured agentChat.* debug trace across the pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the agent-session subsystem debuggable end to end. DEBUG-gated cmuxDebugLog lines with a consistent `agentChat.*` prefix at every decision point, so one `grep 'agentChat\.' /tmp/cmux-debug-.log` shows the whole flow when a bug like a missing question/transcript happens: - agentChat.hook — every hook event ingested (event name, tool name incl. AskUserQuestion, has-toolInput, surface, has-transcript). - agentChat.detect — observe-floor process-tree detections (session, kind, surface, pid, id resolved via fd vs argv, new/bind). - agentChat.state — every state transition at the single update() chokepoint (idle/working/needsInput/ended, version). - agentChat.transcript.resolve — transcript path resolution (file or UNRESOLVED with kind+cwd, so home-dir / config-dir misses are obvious). - agentChat.transcript.batch — each tail batch (appended/updated/reset/title counts), so "did transcript content actually stream" is visible. All DEBUG-only and off the typing path. Covers detection, tool use, and transcript stuff in one greppable trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentChat/AgentChatSessionRegistry.swift | 33 +++++++++++++++++++ .../AgentChatTranscriptService.swift | 19 +++++++++++ 2 files changed, 52 insertions(+) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 15a6499ca331..19c34076507d 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -168,6 +168,14 @@ final class AgentChatSessionRegistry { private func applyObservedSessions(_ observed: [ObservedAgentSession]) { let now = Date() for session in observed { + #if DEBUG + cmuxDebugLog( + "agentChat.detect session=\(session.sessionID.prefix(8)) kind=\(session.agentKind.sourceName) " + + "surface=\(session.surfaceID.prefix(8)) pid=\(session.pid) " + + "transcript=\(session.transcriptPath != nil ? "fd" : "argv-only") " + + "\(records[session.sessionID] == nil ? "new" : "bind-existing")" + ) + #endif if records[session.sessionID] == nil { var record = AgentChatSessionRecord( sessionID: session.sessionID, @@ -412,11 +420,27 @@ final class AgentChatSessionRegistry { mutate(&record) stampVersion(&record) records[sessionID] = record + #if DEBUG + if previous.state != record.state { + cmuxDebugLog( + "agentChat.state session=\(sessionID.prefix(8)) " + + "\(Self.stateLabel(previous.state))->\(Self.stateLabel(record.state)) v\(record.version)" + ) + } + #endif syncProcessExitWatch(for: record) updateLiveSessionIndex(previous: previous, current: record) onRecordChanged?(record, previous) } + #if DEBUG + /// Compact state label for the debug trace (`idle`/`working`/`needsInput`/ + /// `ended`), stripping any associated value. + private static func stateLabel(_ state: ChatAgentState) -> String { + String(describing: state).split(separator: "(").first.map(String.init) ?? "?" + } + #endif + /// A transcript tail can observe a completed assistant turn even when /// the agent hook stream never emits Stop (Claude weekly-limit replies /// do this). Use that transcript fact only to clear an active working @@ -478,6 +502,15 @@ final class AgentChatSessionRegistry { func noteHookEvent(_ event: WorkstreamEvent) -> AgentChatSessionRecord { let sessionID = Self.normalizedSessionID(event.sessionId, source: event.source) let kind = ChatAgentKind(source: event.source) + #if DEBUG + cmuxDebugLog( + "agentChat.hook session=\(sessionID.prefix(8)) event=\(event.hookEventName.rawValue) " + + "source=\(event.source) tool=\(event.toolName ?? "-") " + + "toolInput=\(event.toolInputJSON != nil ? "yes" : "no") " + + "surface=\((event.surfaceId ?? "nil").prefix(8)) " + + "transcript=\(event.transcriptPath != nil ? "yes" : "no")" + ) + #endif var record = records[sessionID] ?? AgentChatSessionRecord( sessionID: sessionID, agentKind: kind, diff --git a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift index bcde02dc53c7..2d2357e3619a 100644 --- a/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift +++ b/Sources/Mobile/AgentChat/AgentChatTranscriptService.swift @@ -269,8 +269,20 @@ final class AgentChatTranscriptService { guard !failedResolutions.contains(record.sessionID) else { return nil } guard let path = resolver.transcriptPath(for: record) else { failedResolutions.insert(record.sessionID) + #if DEBUG + cmuxDebugLog( + "agentChat.transcript.resolve session=\(record.sessionID.prefix(8)) " + + "kind=\(record.agentKind.sourceName) cwd=\(record.workingDirectory ?? "nil") UNRESOLVED" + ) + #endif return nil } + #if DEBUG + cmuxDebugLog( + "agentChat.transcript.resolve session=\(record.sessionID.prefix(8)) " + + "file=\((path as NSString).lastPathComponent)" + ) + #endif if record.transcriptPath != path { registry.update(sessionID: record.sessionID) { $0.transcriptPath = path } } @@ -289,6 +301,13 @@ final class AgentChatTranscriptService { } private func publishBatch(_ batch: AgentChatTranscriptTailer.Batch, sessionID: String) { + #if DEBUG + cmuxDebugLog( + "agentChat.transcript.batch session=\(sessionID.prefix(8)) " + + "appended=\(batch.appended.count) updated=\(batch.updated.count) " + + "reset=\(batch.didReset ? 1 : 0) title=\(batch.discoveredTitle != nil ? 1 : 0)" + ) + #endif if batch.didReset { emit(frame: ChatSessionEventFrame(sessionID: sessionID, event: .reset)) } From cd04d728a04e7faffa31d79cb6158550ab9f811b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 21:21:12 -0700 Subject: [PATCH 29/38] agent-chat: render Codex request_user_input pickers as tappable GUI questions Interactive pickers in the GUI were Claude-only: ClaudeTranscriptParser turns an AskUserQuestion tool into a tappable .question node, but CodexTranscriptParser produced none, so a Codex picker streamed into the GUI as plain text with no way to select. Codex writes its picker as a `request_user_input` function_call whose arguments carry `questions[]` in the exact same shape as Claude's AskUserQuestion (question + options[].label/description). Parse it into the same ChatQuestion node, one tappable question per entry. And make mobile.chat.answer agent-aware: Claude submits on the digit alone, Codex's picker needs Enter, so append a carriage return for codex. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Parsing/CodexTranscriptParser.swift | 39 +++++++++++++++++++ Sources/TerminalController+MobileChat.swift | 7 +++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift index 6d52db561fc8..79877d70f2eb 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Parsing/CodexTranscriptParser.swift @@ -205,6 +205,28 @@ 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) + ) + ) + } + return + } + } let kind: ChatMessageKind if Self.shellToolNames.contains(name), let command = shellCommand(arguments: parsedArguments, payload: payload) { @@ -228,6 +250,23 @@ 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) + } + } + private func appendCustomToolCall( _ payload: TranscriptJSONValue, seq: Int, diff --git a/Sources/TerminalController+MobileChat.swift b/Sources/TerminalController+MobileChat.swift index 50cf92be3f11..4fa1cdae0440 100644 --- a/Sources/TerminalController+MobileChat.swift +++ b/Sources/TerminalController+MobileChat.swift @@ -320,8 +320,13 @@ extension TerminalController { "session_id": sessionID ]) } + // Claude's picker submits on the digit alone; Codex's `request_user_input` + // picker highlights on the digit and needs Enter to submit ("enter to + // submit answer"), so append a carriage return for codex. let digit = String(optionIndex + 1) - let sendResult = terminalPanel.surface.sendInputResult(digit) + let isCodex = agentChatTranscriptService?.sessionRecord(sessionID: sessionID)?.agentKind == .codex + let answerKeys = isCodex ? "\(digit)\r" : digit + let sendResult = terminalPanel.surface.sendInputResult(answerKeys) switch sendResult { case .sent, .queued: terminalPanel.surface.forceRefresh(reason: "mobileHost.chatAnswer") From 028605277b1d3634440df9ea58df22d60f338ad6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 25 Jun 2026 21:29:20 -0700 Subject: [PATCH 30/38] agent-chat: mark answered Codex pickers resolved (show selection, stop tapping) Codex pickers rendered as tappable questions but never resolved, so they stayed interactive forever (even past, answered ones) and never showed the chosen option. Codex pairs the answer to its request_user_input call via a function_call_output whose JSON is {"answers":{"":{"answers":["