diff --git a/Glint/Agent/AgentBridge.swift b/Glint/Agent/AgentBridge.swift index c16f8318..54631b3e 100644 --- a/Glint/Agent/AgentBridge.swift +++ b/Glint/Agent/AgentBridge.swift @@ -171,12 +171,14 @@ final class AgentBridge { let sessionB64: String? let transcriptB64: String? let turnB64: String? + let cwdB64: String? private enum CodingKeys: String, CodingKey { case pane, hook, agent case sessionB64 = "session_b64" case transcriptB64 = "transcript_b64" case turnB64 = "turn_b64" + case cwdB64 = "cwd_b64" } } @@ -186,6 +188,12 @@ final class AgentBridge { return nil } var result = ["pane": env.pane, "hook": env.hook] + if let encoded = env.cwdB64, + let data = Data(base64Encoded: encoded), + let value = String(data: data, encoding: .utf8), + !value.isEmpty { + result["cwd"] = value + } if let agent = env.agent, !agent.isEmpty { result["agent"] = agent } if let encoded = env.sessionB64, let data = Data(base64Encoded: encoded), diff --git a/Glint/Agent/AgentHookInstaller.swift b/Glint/Agent/AgentHookInstaller.swift index ea512887..0bfd816a 100644 --- a/Glint/Agent/AgentHookInstaller.swift +++ b/Glint/Agent/AgentHookInstaller.swift @@ -399,6 +399,7 @@ enum AgentHookInstaller { SESSION="" TRANSCRIPT="" TURN="" + EVENT_CWD="" if TMP=$(/usr/bin/mktemp "${TMPDIR:-/tmp}/glint-hook.XXXXXX"); then trap '/bin/rm -f "$TMP"' EXIT HUP INT TERM cat >"$TMP" @@ -429,6 +430,11 @@ enum AgentHookInstaller { TRANSCRIPT=$(/usr/bin/plutil -extract transcript_path raw -o - "$TMP" 2>/dev/null || true) TURN=$(/usr/bin/plutil -extract turn_id raw -o - "$TMP" 2>/dev/null || true) fi + # A shared Codex daemon can inherit a different client's pane env. + # Preserve the event's own cwd so Glint can verify its destination. + if [ "$AGENT" = "codex" ]; then + EVENT_CWD=$(/usr/bin/plutil -extract cwd raw -o - "$TMP" 2>/dev/null || true) + fi # Antigravity (agy) has no UserPromptSubmit event; the turn's first # PreInvocation plays that role — remap it so the turn timer starts # with the turn instead of the first tool call. Verified against the @@ -452,11 +458,15 @@ enum AgentHookInstaller { fi APPROVAL_META="" + if [ -n "$EVENT_CWD" ]; then + CWD_B64=$(printf '%s' "$EVENT_CWD" | /usr/bin/base64 | /usr/bin/tr -d '\\r\\n') + APPROVAL_META=$(printf ',"cwd_b64":"%s"' "$CWD_B64") + fi if [ -n "$TRANSCRIPT" ] && [ -n "$TURN" ]; then TRANSCRIPT_B64=$(printf '%s' "$TRANSCRIPT" | /usr/bin/base64 | /usr/bin/tr -d '\\r\\n') TURN_B64=$(printf '%s' "$TURN" | /usr/bin/base64 | /usr/bin/tr -d '\\r\\n') - APPROVAL_META=$(printf ',"transcript_b64":"%s","turn_b64":"%s"' \\ - "$TRANSCRIPT_B64" "$TURN_B64") + APPROVAL_META=$(printf '%s,"transcript_b64":"%s","turn_b64":"%s"' \\ + "$APPROVAL_META" "$TRANSCRIPT_B64" "$TURN_B64") fi if [ -n "$SESSION" ]; then diff --git a/Glint/Workspace/WorkspaceStore.swift b/Glint/Workspace/WorkspaceStore.swift index 9caa338f..bfd03907 100644 --- a/Glint/Workspace/WorkspaceStore.swift +++ b/Glint/Workspace/WorkspaceStore.swift @@ -2081,11 +2081,13 @@ final class WorkspaceStore: ObservableObject { NSLog("[glint] pane process changed: ws=\(key.workspace.uuidString.prefix(8)) pane=\(key.pane.value) -> \(name)") } - private func reconcileAgentState(key: WorkspacePaneKey, processName: String) { + func reconcileAgentState(key: WorkspacePaneKey, processName: String) { guard var state = paneAgentState[key] else { return } guard let runningKind = Self.agentKind(named: processName) else { - if state.status == .idle, Self.isBenignShellProcessName(processName) { + if Self.isBenignShellProcessName(processName), + state.status == .idle || Self.isBusyStatus(state.status) { paneAgentState.removeValue(forKey: key) + clearDockBadge(for: key) } return } @@ -2100,6 +2102,41 @@ final class WorkspaceStore: ObservableObject { // MARK: agent hook events + /// Codex's shared app-server can keep the pane environment of the client + /// that first started it. Verify against attached CLIs before allowing an + /// event to mutate status or persisted resume IDs. A known session wins; + /// otherwise only a unique active pane with the event's cwd is safe. + private func codexHookTarget(addressedKey: WorkspacePaneKey, + session: String?, cwd: String?) -> WorkspacePaneKey? { + var attached: [(key: WorkspacePaneKey, pane: Pane, cwd: String?)] = [] + for workspace in workspaces { + for pane in workspace.panes.values { + let key = WorkspacePaneKey(workspace: workspace.id, pane: pane.id) + let view = surfaceViews[key] + let process = view?.foregroundProcessName() ?? paneProcesses[key] + guard process.flatMap(Self.agentKind(named:)) == .codex else { continue } + attached.append((key, pane, view?.currentCwd() ?? pane.workingDirectory)) + } + } + if let session, Self.isValidSessionId(session) { + let matches = attached.filter { $0.pane.sessionIds[PaneAgentKind.codex.rawValue] == session } + if !matches.isEmpty { return matches.count == 1 ? matches[0].key : nil } + } + if let cwd, !cwd.isEmpty { + // Never treat a missing/relative cwd as the current directory. + guard cwd.hasPrefix("/") else { return nil } + let eventPath = URL(fileURLWithPath: cwd).standardizedFileURL.resolvingSymlinksInPath().path + let matches = attached.filter { + guard let path = $0.cwd, path.hasPrefix("/"), $0.pane.remoteTarget == nil else { return false } + return URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath().path == eventPath + } + return matches.count == 1 ? matches[0].key : nil + } + // Compatibility with older reporters: accept the supplied address + // only while a Codex CLI is actually attached there. + return attached.first(where: { $0.key == addressedKey })?.key + } + static func permissionRequestStatus( kind: PaneAgentKind, approvalsReviewer: String? @@ -2120,9 +2157,21 @@ final class WorkspaceStore: ObservableObject { guard let info, let paneStr = info["pane"] as? String, let hook = info["hook"] as? String, - let key = Self.parsePaneKey(paneStr) else { return } + let addressedKey = Self.parsePaneKey(paneStr) else { return } let explicitKind = (info["agent"] as? String).flatMap(Self.agentKind(named:)) + let key: WorkspacePaneKey + if explicitKind == .codex { + guard let target = codexHookTarget( + addressedKey: addressedKey, + session: info["session"] as? String, + cwd: info["cwd"] as? String + ) else { return } + key = target + } else { + key = addressedKey + } + guard paneExists(key) else { return } let foregroundKind = surfaceViews[key]?.foregroundProcessName() .flatMap(Self.agentKind(named:)) let polledKind = paneProcesses[key].flatMap(Self.agentKind(named:)) diff --git a/GlintTests/AgentHookRoutingTests.swift b/GlintTests/AgentHookRoutingTests.swift index d072e13c..31376694 100644 --- a/GlintTests/AgentHookRoutingTests.swift +++ b/GlintTests/AgentHookRoutingTests.swift @@ -3,6 +3,126 @@ import XCTest @testable import Glint final class AgentHookRoutingTests: XCTestCase { + @MainActor + private func codexRoutingFixture() throws -> (WorkspaceStore, WorkspaceStore.WorkspacePaneKey, WorkspaceStore.WorkspacePaneKey) { + var c1 = Workspace.fresh(name: "C1Slim", accentHex: "5E5CE6", symbol: "C") + var glint = Workspace.fresh(name: "Glint", accentHex: "5E5CE6", symbol: "G") + let c1Pane = try XCTUnwrap(c1.selectedTab?.focusedPane) + let glintPane = try XCTUnwrap(glint.selectedTab?.focusedPane) + c1.panes[c1Pane]?.workingDirectory = "/tmp/C1Slim" + glint.panes[glintPane]?.workingDirectory = "/tmp/glint" + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [c1, glint] + let c1Key = WorkspaceStore.WorkspacePaneKey(workspace: c1.id, pane: c1Pane) + let glintKey = WorkspaceStore.WorkspacePaneKey(workspace: glint.id, pane: glintPane) + store.paneProcesses[c1Key] = "zsh" + store.paneProcesses[glintKey] = "codex" + return (store, c1Key, glintKey) + } + + @MainActor + func testCodexDaemonHookRoutesToUniqueActivePaneByEventCwd() throws { + let (store, c1, glint) = try codexRoutingFixture() + for hook in ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"] { + let decoded = try XCTUnwrap(runReporter( + hook: hook, agent: "codex", + pane: "\(c1.workspace.uuidString):\(c1.pane.value)", + stdinJSON: #"{"session_id":"glint-session","cwd":"/tmp/glint"}"#, + extraEnv: nil, expectSocketTraffic: true + )) + store.handleAgentEvent(decoded) + XCTAssertNil(store.paneAgentState[c1]) + XCTAssertNotNil(store.paneAgentState[glint]) + XCTAssertNil(store.workspaces[0].panes[c1.pane]?.sessionIds["codex"]) + XCTAssertEqual(store.workspaces[1].panes[glint.pane]?.sessionIds["codex"], "glint-session") + } + XCTAssertFalse(WorkspaceStore.isBusyStatus(try XCTUnwrap(store.paneAgentState[glint]).status)) + } + + @MainActor + func testCodexSessionBindingSurvivesToolWorkingDirectoryChange() throws { + let (store, c1, glint) = try codexRoutingFixture() + store.workspaces[1].panes[glint.pane]?.sessionIds = ["codex": "glint-session"] + store.handleAgentEvent([ + "pane": "\(c1.workspace.uuidString):\(c1.pane.value)", + "agent": "codex", "hook": "PreToolUse", "session": "glint-session", "cwd": "/tmp/elsewhere", + ]) + XCTAssertNil(store.paneAgentState[c1]) + XCTAssertEqual(store.paneAgentState[glint]?.status, .tool) + } + + @MainActor + func testCodexSameCwdAmbiguityDoesNotGuessFromDaemonPaneEnvironment() throws { + let (store, c1, glint) = try codexRoutingFixture() + store.paneProcesses[c1] = "codex" + store.workspaces[0].panes[c1.pane]?.workingDirectory = "/tmp/glint" + store.handleAgentEvent([ + "pane": "\(c1.workspace.uuidString):\(c1.pane.value)", + "agent": "codex", "hook": "UserPromptSubmit", "session": "new-session", "cwd": "/tmp/glint", + ]) + XCTAssertNil(store.paneAgentState[c1]) + XCTAssertNil(store.paneAgentState[glint]) + store.workspaces[1].panes[glint.pane]?.sessionIds = ["codex": "new-session"] + store.handleAgentEvent([ + "pane": "\(c1.workspace.uuidString):\(c1.pane.value)", + "agent": "codex", "hook": "UserPromptSubmit", "session": "new-session", "cwd": "/tmp/glint", + ]) + XCTAssertNil(store.paneAgentState[c1]) + XCTAssertEqual(store.paneAgentState[glint]?.status, .thinking) + } + + @MainActor + func testLegacyCodexHookStillWorksInAttachedPane() throws { + let (store, _, glint) = try codexRoutingFixture() + store.handleAgentEvent([ + "pane": "\(glint.workspace.uuidString):\(glint.pane.value)", + "agent": "codex", "hook": "UserPromptSubmit", "session": "glint-session", + ]) + XCTAssertEqual(store.paneAgentState[glint]?.status, .thinking) + } + + @MainActor + func testReturningToShellClearsBusyStateButKeepsCompletionAttention() throws { + let (store, c1, _) = try codexRoutingFixture() + for status in [PaneAgentStatus.idle, .thinking, .tool, .compacting, .needsPermission] { + store.paneAgentState[c1] = PaneAgentState(kind: .codex, status: status, detail: nil, updatedAt: Date()) + store.reconcileAgentState(key: c1, processName: "zsh") + XCTAssertNil(store.paneAgentState[c1], "\(status) must not outlive its CLI") + } + for status in [PaneAgentStatus.justCompleted, .failed, .needsReply] { + store.paneAgentState[c1] = PaneAgentState(kind: .codex, status: status, detail: nil, updatedAt: Date()) + store.reconcileAgentState(key: c1, processName: "zsh") + XCTAssertEqual(store.paneAgentState[c1]?.status, status) + } + store.paneAgentState[c1] = PaneAgentState(kind: .codex, status: .thinking, detail: nil, updatedAt: Date()) + store.reconcileAgentState(key: c1, processName: "python3") + XCTAssertEqual(store.paneAgentState[c1]?.status, .thinking) + } + + @MainActor + func testDetachedCodexHooksDoNotMarkShellPaneThinkingOrReplaceSession() throws { + var workspace = Workspace.fresh(name: "C1Slim", accentHex: "5E5CE6", symbol: "C") + let pane = try XCTUnwrap(workspace.selectedTab?.focusedPane) + workspace.panes[pane]?.workingDirectory = "/tmp/C1Slim" + workspace.panes[pane]?.sessionIds = ["codex": "c1-session"] + let store = WorkspaceStore(activity: PaneActivityStore()) + store.workspaces = [workspace] + let key = WorkspaceStore.WorkspacePaneKey(workspace: workspace.id, pane: pane) + store.paneProcesses[key] = "zsh" + + // A shared Codex daemon keeps the environment of the terminal that + // started it, even when another client starts a different session. + for hook in ["UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"] { + store.handleAgentEvent([ + "pane": "\(workspace.id.uuidString):\(pane.value)", + "agent": "codex", "hook": hook, "session": "glint-session", + ]) + XCTAssertNil(store.paneAgentState[key], "\(hook) must not target a detached shell pane") + XCTAssertEqual(store.workspaces[0].panes[pane]?.sessionIds["codex"], "c1-session") + XCTAssertNil(store.agentSummary(for: store.workspaces[0])) + } + } + func testLegacyCanonicalRebindCannotStrandActiveBridge() throws { let root = URL(fileURLWithPath: "/tmp/gb-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -122,8 +242,6 @@ final class AgentHookRoutingTests: XCTestCase { XCTAssertTrue(body.contains("NeedsReply")) XCTAssertFalse(body.contains("agent-debug.sock"), "broadcast fallback should be gone after the direct-route revert") - XCTAssertFalse(body.contains("cwd_b64"), - "cwd metadata is no longer needed once routing is pane-based") let temp = FileManager.default.temporaryDirectory .appendingPathComponent("glint-reporter-\(UUID().uuidString)") @@ -194,6 +312,7 @@ final class AgentHookRoutingTests: XCTestCase { "session": "session-123", "turn": "turn-123", "transcript": "/tmp/codex.jsonl", + "cwd": "/tmp/repo", ]) }