Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Glint/Agent/AgentBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand All @@ -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),
Expand Down
14 changes: 12 additions & 2 deletions Glint/Agent/AgentHookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
55 changes: 52 additions & 3 deletions Glint/Workspace/WorkspaceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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?
Expand All @@ -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:))
Expand Down
123 changes: 121 additions & 2 deletions GlintTests/AgentHookRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -194,6 +312,7 @@ final class AgentHookRoutingTests: XCTestCase {
"session": "session-123",
"turn": "turn-123",
"transcript": "/tmp/codex.jsonl",
"cwd": "/tmp/repo",
])
}

Expand Down
Loading