From b2cdc7a7bbc7905d14dcb8debbf8f83bc40a84e2 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:13:00 -0400 Subject: [PATCH 1/9] Add an agent activity model and resume-safety classifier AgentActivity splits the coarse working/needs-input lifecycle into what an agent is doing now (thinking, a tool with its command and start time, subagents, background work, a question, a permission) and ResumeSafety classifies whether a restart or update can interrupt it (safe, care, risky, with reasons). Pure package code with tests; the app wiring follows. Refs #15266 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxAgentChat/Model/AgentActivity.swift | 200 ++++++++++++++++++ .../AgentActivityClassifierTests.swift | 108 ++++++++++ 2 files changed, 308 insertions(+) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityClassifierTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift new file mode 100644 index 000000000000..bd5320b9d185 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift @@ -0,0 +1,200 @@ +import Foundation + +/// What an agent is doing right now, precise enough to answer "is it truly idle?". +/// +/// ``ChatAgentState`` is the coarse lifecycle the sidebar shows. This splits its +/// `working` and `needsInput` cases into the activities a restart, update or +/// hibernation decision needs to tell apart. Field names are the wire names used +/// by `cmux agents --json` and the updater. +public struct AgentActivity: Sendable, Equatable, Codable { + public enum Kind: String, Sendable, Codable, CaseIterable { + /// The turn is over and nothing is pending. + case idle + /// The turn is over and the prompt waits on a human. + case awaitingInput = "awaiting_input" + /// An AskUserQuestion or plan approval is open. + case question + /// Blocked on a permission request. + case permission + /// Mid-turn with no tool running: a model request is in flight. + case thinking + /// Inside a tool call; see ``AgentActivity/tool``. + case tool + /// Inside a Task call whose subagents are running. + case subagents + /// The turn stopped, but a background task or scheduled wakeup is live. + case background + case ended + case unknown + } + + public struct Tool: Sendable, Equatable, Codable { + public var name: String + /// The Bash command, or a one-line summary of the tool input. + public var command: String? + public var startedAt: Date? + + public init(name: String, command: String? = nil, startedAt: Date? = nil) { + self.name = name + self.command = command + self.startedAt = startedAt + } + + private enum CodingKeys: String, CodingKey { + case name, command + case startedAt = "started_at" + } + } + + /// Which evidence decided the kind. `process` means hooks were silent or stale + /// and the pane's process tree showed a foreground command. + public enum Source: String, Sendable, Codable { + case hook, transcript, screen, process + } + + public var kind: Kind + public var tool: Tool? + /// When this kind began; "for how long" is `now - since`. + public var since: Date? + public var source: Source + + public init(kind: Kind, tool: Tool? = nil, since: Date? = nil, source: Source) { + self.kind = kind + self.tool = tool + self.since = since + self.source = source + } +} + +/// Whether an agent can be interrupted and resumed (restart, update, hibernation) +/// without losing work. Advisory: each consumer decides what to do with it. +public enum ResumeSafety: String, Sendable, Codable, Comparable { + /// Idle, awaiting input, or between tool calls. + case safe + /// A model request, subagents, background work or a read-only tool in flight; + /// resuming and re-issuing handles it. + case care + /// A foreground command, an unanswered question or permission, or a draft. + case risky + + public static func < (lhs: ResumeSafety, rhs: ResumeSafety) -> Bool { + let order: [ResumeSafety] = [.safe, .care, .risky] + return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)! + } +} + +public struct ResumeSafetyAssessment: Sendable, Equatable, Codable { + public enum Reason: String, Sendable, Codable { + case idle + case awaitingInput = "awaiting_input" + case betweenToolCalls = "between_tool_calls" + case thinking + case readOnlyTool = "read_only_tool" + case foregroundCommand = "foreground_command" + case subagents + case backgroundWork = "background_work" + case openQuestion = "open_question" + case pendingPermission = "pending_permission" + case draft + case ended + case unknown + } + + public var safety: ResumeSafety + public var reasons: [Reason] + + public init(safety: ResumeSafety, reasons: [Reason]) { + self.safety = safety + self.reasons = reasons + } +} + +/// The facts the app gathers about one agent pane. Every field is optional +/// evidence; the classifier never guesses past what it is given. +public struct AgentActivitySignals: Sendable, Equatable { + public var ended = false + public var pendingPermission = false + public var pendingQuestion = false + /// A PreToolUse with no matching PostToolUse yet. + public var openTool: AgentActivity.Tool? + /// Between UserPromptSubmit and Stop. + public var turnActive = false + /// The last hook in an active turn was a PostToolUse. + public var lastToolFinished = false + /// Stop fired with background tasks or scheduled wakeups still live. + public var backgroundWork = false + /// The turn ended waiting on a human (idle prompt notification). + public var awaitingInput = false + /// A live, non-background child in the agent's foreground process group, + /// described by its argv. Holds even when hooks are stale. + public var foregroundCommand: String? + /// Whether the agent's prompt holds a half-typed draft; nil when unknown. + public var hasDraft: Bool? + /// When the latest observed transition happened. + public var since: Date? + /// Whether any lifecycle hook has ever reported for this pane. + public var hasHookEvidence = true + + public init() {} +} + +public enum AgentActivityClassifier { + /// Tools that only read. A resume re-issues them without side effects. + public static let readOnlyTools: Set = [ + "Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead", "TodoRead", + ] + + /// Subagent launchers: time inside them is subagent work, not a foreground tool. + public static let subagentTools: Set = ["Task", "Agent"] + + public static func classify(_ signals: AgentActivitySignals) -> (activity: AgentActivity, safety: ResumeSafetyAssessment) { + let since = signals.since + let (activity, safety, reason): (AgentActivity, ResumeSafety, ResumeSafetyAssessment.Reason) = { + if signals.ended { + return (AgentActivity(kind: .ended, since: since, source: .hook), .safe, .ended) + } + if signals.pendingPermission { + return (AgentActivity(kind: .permission, tool: signals.openTool, since: since, source: .hook), .risky, .pendingPermission) + } + if signals.pendingQuestion { + return (AgentActivity(kind: .question, since: since, source: .hook), .risky, .openQuestion) + } + if let tool = signals.openTool { + if subagentTools.contains(tool.name) { + return (AgentActivity(kind: .subagents, tool: tool, since: tool.startedAt ?? since, source: .hook), .care, .subagents) + } + if readOnlyTools.contains(tool.name) { + return (AgentActivity(kind: .tool, tool: tool, since: tool.startedAt ?? since, source: .hook), .care, .readOnlyTool) + } + return (AgentActivity(kind: .tool, tool: tool, since: tool.startedAt ?? since, source: .hook), .risky, .foregroundCommand) + } + // Hooks can go stale; a live foreground child is a running command. + if let command = signals.foregroundCommand { + let tool = AgentActivity.Tool(name: "process", command: command) + return (AgentActivity(kind: .tool, tool: tool, since: since, source: .process), .risky, .foregroundCommand) + } + if signals.turnActive { + return (AgentActivity(kind: .thinking, since: since, source: .hook), + signals.lastToolFinished ? .safe : .care, + signals.lastToolFinished ? .betweenToolCalls : .thinking) + } + if signals.backgroundWork { + return (AgentActivity(kind: .background, since: since, source: .hook), .care, .backgroundWork) + } + if !signals.hasHookEvidence { + return (AgentActivity(kind: .unknown, since: since, source: .process), .care, .unknown) + } + if signals.awaitingInput { + return (AgentActivity(kind: .awaitingInput, since: since, source: .hook), .safe, .awaitingInput) + } + return (AgentActivity(kind: .idle, since: since, source: .hook), .safe, .idle) + }() + var assessment = ResumeSafetyAssessment(safety: safety, reasons: [reason]) + // A draft is unsaved human input: never safe to drop, whatever the agent does. + if signals.hasDraft == true, activity.kind != .ended { + assessment.safety = .risky + assessment.reasons.append(.draft) + } + return (activity, assessment) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityClassifierTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityClassifierTests.swift new file mode 100644 index 000000000000..48d869fb374c --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityClassifierTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Agent activity and resume safety") +struct AgentActivityClassifierTests { + private func classify(_ configure: (inout AgentActivitySignals) -> Void) -> (AgentActivity, ResumeSafetyAssessment) { + var signals = AgentActivitySignals() + configure(&signals) + let result = AgentActivityClassifier.classify(signals) + return (result.activity, result.safety) + } + + @Test("an idle agent with no pending work is safe") + func idle() { + let (activity, safety) = classify { _ in } + #expect(activity.kind == .idle) + #expect(safety == ResumeSafetyAssessment(safety: .safe, reasons: [.idle])) + } + + @Test("awaiting a human at the prompt is safe") + func awaitingInput() { + let (activity, safety) = classify { $0.awaitingInput = true } + #expect(activity.kind == .awaitingInput) + #expect(safety.safety == .safe) + } + + @Test("a foreground Bash command is risky and carries its command") + func foregroundTool() { + let started = Date(timeIntervalSince1970: 100) + let (activity, safety) = classify { + $0.turnActive = true + $0.openTool = .init(name: "Bash", command: "swift build", startedAt: started) + } + #expect(activity.kind == .tool) + #expect(activity.tool?.command == "swift build") + #expect(activity.since == started) + #expect(safety.safety == .risky) + #expect(safety.reasons == [.foregroundCommand]) + } + + @Test("read-only tools and model requests need care; between tool calls is safe") + func careCases() { + #expect(classify { $0.turnActive = true; $0.openTool = .init(name: "Grep") }.1.safety == .care) + let thinking = classify { $0.turnActive = true } + #expect(thinking.0.kind == .thinking) + #expect(thinking.1 == ResumeSafetyAssessment(safety: .care, reasons: [.thinking])) + let between = classify { $0.turnActive = true; $0.lastToolFinished = true } + #expect(between.0.kind == .thinking) + #expect(between.1 == ResumeSafetyAssessment(safety: .safe, reasons: [.betweenToolCalls])) + } + + @Test("subagents and background work need care, not risky") + func subagentsAndBackground() { + let task = classify { $0.turnActive = true; $0.openTool = .init(name: "Task") } + #expect(task.0.kind == .subagents) + #expect(task.1.safety == .care) + let background = classify { $0.backgroundWork = true } + #expect(background.0.kind == .background) + #expect(background.1 == ResumeSafetyAssessment(safety: .care, reasons: [.backgroundWork])) + } + + @Test("an open question or permission is risky and outranks a running tool") + func blockedOnHuman() { + let permission = classify { $0.pendingPermission = true; $0.openTool = .init(name: "Bash", command: "rm -rf build") } + #expect(permission.0.kind == .permission) + #expect(permission.0.tool?.name == "Bash") + #expect(permission.1.reasons == [.pendingPermission]) + #expect(classify { $0.pendingQuestion = true }.1.safety == .risky) + } + + @Test("a live foreground child marks a command even when hooks say idle") + func processTreeWinsOverStaleHooks() { + let (activity, safety) = classify { $0.foregroundCommand = "ssh build-host make" } + #expect(activity.kind == .tool) + #expect(activity.source == .process) + #expect(activity.tool?.command == "ssh build-host make") + #expect(safety.safety == .risky) + } + + @Test("a draft makes any live state risky, but not an ended one") + func draft() { + let idle = classify { $0.hasDraft = true } + #expect(idle.1 == ResumeSafetyAssessment(safety: .risky, reasons: [.idle, .draft])) + let ended = classify { $0.ended = true; $0.hasDraft = true } + #expect(ended.1.safety == .safe) + } + + @Test("no hook evidence is unknown, not idle") + func unknown() { + let (activity, safety) = classify { $0.hasHookEvidence = false } + #expect(activity.kind == .unknown) + #expect(safety.reasons == [.unknown]) + } + + @Test("wire names match the agents view and updater contract") + func wireNames() throws { + let activity = AgentActivity(kind: .awaitingInput, tool: .init(name: "Bash", startedAt: Date(timeIntervalSince1970: 0)), source: .hook) + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + encoder.dateEncodingStrategy = .iso8601 + let json = String(decoding: try encoder.encode(activity), as: UTF8.self) + #expect(json == #"{"kind":"awaiting_input","source":"hook","tool":{"name":"Bash","started_at":"1970-01-01T00:00:00Z"}}"#) + #expect(ResumeSafety.safe < .care && ResumeSafety.care < .risky) + #expect(ResumeSafetyAssessment.Reason.betweenToolCalls.rawValue == "between_tool_calls") + } +} From e9ef0a02752508e3d24e7f1688c31139b77cc701 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:29:07 -0400 Subject: [PATCH 2/9] Fold agent hooks and process facts into activity signals AgentHookActivityState folds one session's queued hooks (prompt submit, pre and post tool use by tool_use_id, stop with background work, idle notification, session start and end) into turn facts. AgentActivityEvidence combines them with the session registry state, the Feed decision overlay and a foreground command into AgentActivitySignals. AgentForegroundCommand picks the command an agent runs through a foreground shell child, so MCP servers and background shells do not count. Refs #15266 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Model/AgentActivitySignalAssembly.swift | 76 ++++++ .../Model/AgentForegroundCommand.swift | 86 +++++++ .../Model/AgentHookActivityState.swift | 189 +++++++++++++++ .../AgentActivityEvidenceTests.swift | 224 ++++++++++++++++++ 4 files changed, 575 insertions(+) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift create mode 100644 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift new file mode 100644 index 000000000000..5a278ec0eb28 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Combines the app's per-pane evidence into ``AgentActivitySignals``. +public struct AgentActivityEvidence: Sendable { + /// The session registry's coarse lifecycle. + public var registryState: ChatAgentState + /// Whether the registry state came from hooks rather than process discovery. + public var registryHasHookLifecycleState: Bool + public var registryLastActivityAt: Date + /// Turn facts folded from this session's hooks, when any arrived. + public var hooks: AgentHookActivityState? + /// The Feed's needs-input overlay (a permission, question or plan decision) is lit. + public var feedDecisionPending: Bool + /// Description of a command the agent runs in the terminal's foreground. + public var foregroundCommand: String? + public var hasDraft: Bool? + + public init( + registryState: ChatAgentState, + registryHasHookLifecycleState: Bool, + registryLastActivityAt: Date, + hooks: AgentHookActivityState? = nil, + feedDecisionPending: Bool = false, + foregroundCommand: String? = nil, + hasDraft: Bool? = nil + ) { + self.registryState = registryState + self.registryHasHookLifecycleState = registryHasHookLifecycleState + self.registryLastActivityAt = registryLastActivityAt + self.hooks = hooks + self.feedDecisionPending = feedDecisionPending + self.foregroundCommand = foregroundCommand + self.hasDraft = hasDraft + } + + public var signals: AgentActivitySignals { + var signals = AgentActivitySignals() + let registryEnded: Bool + let registrySince: Date? + let registryWorking: Bool + let registryNeedsInput: Bool + switch registryState { + case .idle: + (registryEnded, registrySince, registryWorking, registryNeedsInput) = (false, nil, false, false) + case .working(let since): + (registryEnded, registrySince, registryWorking, registryNeedsInput) = (false, since, true, false) + case .needsInput(let since): + (registryEnded, registrySince, registryWorking, registryNeedsInput) = (false, since, false, true) + case .ended: + (registryEnded, registrySince, registryWorking, registryNeedsInput) = (true, nil, false, false) + } + + signals.ended = registryEnded || hooks?.ended == true + signals.pendingQuestion = hooks?.pendingQuestion == true + // The Feed overlay covers permissions, questions and plan approvals. A + // question the hooks already saw stays a question. + signals.pendingPermission = feedDecisionPending && !signals.pendingQuestion + signals.openTool = hooks?.openTool + signals.turnActive = hooks?.turnActive ?? registryWorking + signals.lastToolFinished = hooks?.lastToolFinished == true + signals.backgroundWork = hooks?.backgroundWork == true + // Without a Feed decision or hook question, the registry's needs-input + // comes from a notification: the turn is over and waits on a human. + signals.awaitingInput = hooks?.awaitingInput == true + || (registryNeedsInput && !feedDecisionPending && !signals.pendingQuestion && !signals.turnActive) + // A background task is a child of the agent too; after Stop it is + // background work, not a foreground command. + if !(signals.backgroundWork && !signals.turnActive) { + signals.foregroundCommand = foregroundCommand + } + signals.hasDraft = hasDraft + signals.since = hooks?.since ?? registrySince ?? registryLastActivityAt + signals.hasHookEvidence = hooks != nil || registryHasHookLifecycleState + return signals + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift new file mode 100644 index 000000000000..f7c5bac68f72 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Finds the command an agent runs in its terminal's foreground from a process census. +/// +/// Agents run tool commands through a shell they spawn (`zsh -c ...`). Long-lived +/// helpers such as MCP servers are also children of the agent but are not shells, +/// so only a shell child in the terminal's foreground process group counts. +public enum AgentForegroundCommand { + /// One process from a census. + public struct Process: Sendable, Equatable { + public var pid: Int + public var parentPID: Int + public var name: String + public var isTerminalForeground: Bool + public var startedAt: Date? + + public init(pid: Int, parentPID: Int, name: String, isTerminalForeground: Bool, startedAt: Date? = nil) { + self.pid = pid + self.parentPID = parentPID + self.name = name + self.isTerminalForeground = isTerminalForeground + self.startedAt = startedAt + } + } + + public static let shellNames: Set = ["sh", "bash", "zsh", "dash", "fish", "ksh", "tcsh", "csh"] + public static let maximumLength = 120 + static let maximumDepth = 32 + + /// The deepest foreground process under the agent's newest foreground shell child. + /// + /// - Parameters: + /// - agentPID: The agent process. + /// - processes: The census, keyed by pid. + /// - notBefore: Ignore shells started earlier (for example before the current turn). + /// - Returns: The pid whose argv describes the command, or nil when none runs. + public static func commandPID(agentPID: Int, processes: [Int: Process], notBefore: Date? = nil) -> Int? { + guard agentPID > 0 else { return nil } + var children: [Int: [Process]] = [:] + for process in processes.values where process.pid != process.parentPID { + children[process.parentPID, default: []].append(process) + } + func newest(_ candidates: [Process]) -> Process? { + candidates.max { lhs, rhs in + (lhs.startedAt ?? .distantPast, lhs.pid) < (rhs.startedAt ?? .distantPast, rhs.pid) + } + } + let shells = (children[agentPID] ?? []).filter { process in + guard process.pid != agentPID, process.isTerminalForeground, + shellNames.contains(normalizedName(process.name)) else { return false } + if let notBefore, let startedAt = process.startedAt, startedAt < notBefore { return false } + return true + } + guard var current = newest(shells) else { return nil } + for _ in 0.. String? { + guard let first = arguments.first else { return nil } + var words = arguments + let executable = normalizedName((first as NSString).lastPathComponent) + if shellNames.contains(executable), + arguments.dropFirst().contains(where: { $0.hasPrefix("-") && !$0.hasPrefix("--") && $0.contains("c") }), + let script = arguments.last, !script.hasPrefix("-") { + words = [script] + } else { + words[0] = (first as NSString).lastPathComponent + } + let line = words.joined(separator: " ") + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !line.isEmpty else { return nil } + return line.count > maximumLength ? String(line.prefix(maximumLength - 1)) + "…" : line + } + + private static func normalizedName(_ name: String) -> String { + name.hasPrefix("-") ? String(name.dropFirst()) : name + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift new file mode 100644 index 000000000000..97275b215baa --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift @@ -0,0 +1,189 @@ +import Foundation + +/// One agent session's turn facts, folded from its lifecycle hooks in arrival order. +/// +/// The app keeps one value per pane and session. It answers what the hooks alone +/// know: whether a turn is running, which tool calls are open, and whether the +/// turn ended with background work or an open question. +public struct AgentHookActivityState: Sendable, Equatable { + /// A lifecycle hook reduced to what activity tracking needs. + public enum Event: Sendable, Equatable { + case sessionStart + case promptSubmit + /// `id` is the hook's `tool_use_id`, when present. + case preToolUse(id: String?, tool: AgentActivity.Tool) + case postToolUse(id: String?, toolName: String?) + case stop(backgroundWork: Bool) + /// `idlePrompt` is true for the "waiting for your input" notification. + case notification(idlePrompt: Bool) + case sessionEnd + } + + /// Tools that open a question or plan approval instead of running. + public static let questionTools: Set = ["AskUserQuestion", "ExitPlanMode"] + + /// Open calls kept per session; older ones are dropped first. + public static let maximumOpenTools = 16 + + private struct OpenTool: Sendable, Equatable { + var id: String? + var tool: AgentActivity.Tool + } + + public private(set) var turnActive = false + public private(set) var lastToolFinished = false + public private(set) var backgroundWork = false + public private(set) var awaitingInput = false + public private(set) var pendingQuestion = false + public private(set) var ended = false + /// When the latest transition happened. + public private(set) var since: Date? + private var openTools: [OpenTool] = [] + + public init() {} + + /// The call that best describes what the agent is doing now: the newest + /// open non-subagent tool, else the newest open subagent launcher. + public var openTool: AgentActivity.Tool? { + openTools.last { !AgentActivityClassifier.subagentTools.contains($0.tool.name) }?.tool + ?? openTools.last?.tool + } + + public mutating func apply(_ event: Event, at date: Date) { + since = date + switch event { + case .sessionStart: + self = AgentHookActivityState() + since = date + case .promptSubmit: + turnActive = true + lastToolFinished = false + backgroundWork = false + awaitingInput = false + pendingQuestion = false + ended = false + openTools.removeAll() + case .preToolUse(let id, let tool): + turnActive = true + lastToolFinished = false + awaitingInput = false + if Self.questionTools.contains(tool.name) { + pendingQuestion = true + return + } + if let id { openTools.removeAll { $0.id == id } } + openTools.append(OpenTool(id: id, tool: tool)) + if openTools.count > Self.maximumOpenTools { + openTools.removeFirst(openTools.count - Self.maximumOpenTools) + } + case .postToolUse(let id, let toolName): + turnActive = true + lastToolFinished = true + if let toolName, Self.questionTools.contains(toolName) { + pendingQuestion = false + return + } + if let id, let index = openTools.lastIndex(where: { $0.id == id }) { + openTools.remove(at: index) + } else if let toolName, let index = openTools.lastIndex(where: { $0.id == nil && $0.tool.name == toolName }) { + openTools.remove(at: index) + } + case .stop(let background): + turnActive = false + lastToolFinished = false + pendingQuestion = false + awaitingInput = false + backgroundWork = background + openTools.removeAll() + case .notification(let idlePrompt): + if idlePrompt, !turnActive { + awaitingInput = true + } + case .sessionEnd: + self = AgentHookActivityState() + ended = true + since = date + } + } +} + +extension AgentHookActivityState.Event { + /// Longest command or summary kept for an open tool. + public static let maximumCommandLength = 200 + + /// Parses one queued hook (`cmux hooks `) payload. + /// + /// - Returns: The event and the hook's session id, or nil when the + /// subcommand carries no activity fact or the payload is not a JSON object. + public static func parse(subcommand: String, payload: Data) -> (event: Self, sessionID: String?)? { + guard let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else { + return nil + } + let sessionID = string(object, ["session_id", "sessionId"]) + let event: Self + switch subcommand { + case "session-start": + event = .sessionStart + case "prompt-submit": + event = .promptSubmit + case "pre-tool-use": + guard let name = string(object, ["tool_name", "toolName"]) else { return nil } + let input = object["tool_input"] as? [String: Any] ?? object["toolInput"] as? [String: Any] ?? [:] + event = .preToolUse( + id: string(object, ["tool_use_id", "toolUseId"]), + tool: AgentActivity.Tool(name: name, command: commandSummary(toolName: name, input: input)) + ) + case "post-tool-use": + event = .postToolUse( + id: string(object, ["tool_use_id", "toolUseId"]), + toolName: string(object, ["tool_name", "toolName"]) + ) + case "stop": + event = .stop(backgroundWork: hasBackgroundWork(object)) + case "notification": + let type = string(object, ["notification_type", "notificationType"]) + event = .notification(idlePrompt: type == "idle_prompt") + case "session-end": + event = .sessionEnd + default: + return nil + } + return (event, sessionID) + } + + /// The Bash command, or the first descriptive input field, on one line. + static func commandSummary(toolName: String, input: [String: Any]) -> String? { + let keys = ["command", "cmd", "file_path", "path", "pattern", "url", "query", "description", "prompt"] + for key in keys { + guard let value = input[key] as? String else { continue } + let line = value.split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !line.isEmpty else { continue } + return line.count > maximumCommandLength + ? String(line.prefix(maximumCommandLength - 1)) + "…" + : line + } + return nil + } + + /// Claude's Stop payload lists running background tasks and pending + /// session crons. Absent keys (older clients) mean none. + static func hasBackgroundWork(_ object: [String: Any]) -> Bool { + if let crons = object["session_crons"] as? [Any], !crons.isEmpty { return true } + if let tasks = object["background_tasks"] as? [[String: Any]] { + return tasks.contains { $0["status"] as? String == "running" } + } + return false + } + + private static func string(_ object: [String: Any], _ keys: [String]) -> String? { + for key in keys { + if let value = (object[key] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty { + return value + } + } + return nil + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift new file mode 100644 index 000000000000..0fd2099fbaab --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift @@ -0,0 +1,224 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Agent activity evidence") +struct AgentActivityEvidenceTests { + private let t0 = Date(timeIntervalSince1970: 1_000) + + private func event(_ subcommand: String, _ json: String) -> AgentHookActivityState.Event? { + AgentHookActivityState.Event.parse(subcommand: subcommand, payload: Data(json.utf8))?.event + } + + private func fold(_ events: [(String, String)]) -> AgentHookActivityState { + var state = AgentHookActivityState() + for (offset, (subcommand, json)) in events.enumerated() { + if let event = event(subcommand, json) { + state.apply(event, at: t0.addingTimeInterval(TimeInterval(offset))) + } + } + return state + } + + private func classify(_ evidence: AgentActivityEvidence) -> (AgentActivity, ResumeSafetyAssessment) { + let result = AgentActivityClassifier.classify(evidence.signals) + return (result.activity, result.safety) + } + + @Test("a PreToolUse without its PostToolUse is an open Bash command") + func openBash() { + let hooks = fold([ + ("prompt-submit", #"{"session_id":"s"}"#), + ("pre-tool-use", #"{"session_id":"s","tool_name":"Bash","tool_use_id":"t1","tool_input":{"command":"swift test\n--parallel"}}"#), + ]) + #expect(hooks.openTool == AgentActivity.Tool(name: "Bash", command: "swift test --parallel")) + let (activity, safety) = classify(.init(registryState: .working(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks)) + #expect(activity.kind == .tool) + #expect(activity.tool?.command == "swift test --parallel") + #expect(safety.safety == .risky) + } + + @Test("PostToolUse closes the call by tool_use_id and leaves the turn between tool calls") + func betweenToolCalls() { + let hooks = fold([ + ("prompt-submit", #"{"session_id":"s"}"#), + ("pre-tool-use", #"{"tool_name":"Read","tool_use_id":"a","tool_input":{"file_path":"/x"}}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"ls"}}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"b"}"#), + ]) + #expect(hooks.openTool?.name == "Read") + let closed = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"ls"}}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"b"}"#), + ]) + #expect(closed.openTool == nil) + let (activity, safety) = classify(.init(registryState: .working(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: closed)) + #expect(activity.kind == .thinking) + #expect(safety.reasons == [.betweenToolCalls]) + } + + @Test("a tool inside a subagent outranks the Task launcher") + func subagentTool() { + let hooks = fold([ + ("pre-tool-use", #"{"tool_name":"Task","tool_use_id":"task","tool_input":{"description":"explore"}}"#), + ("pre-tool-use", #"{"tool_name":"Grep","tool_use_id":"g","tool_input":{"pattern":"foo"}}"#), + ]) + #expect(hooks.openTool?.name == "Grep") + let afterGrep = fold([ + ("pre-tool-use", #"{"tool_name":"Task","tool_use_id":"task","tool_input":{"description":"explore"}}"#), + ("pre-tool-use", #"{"tool_name":"Grep","tool_use_id":"g","tool_input":{"pattern":"foo"}}"#), + ("post-tool-use", #"{"tool_name":"Grep","tool_use_id":"g"}"#), + ]) + #expect(afterGrep.openTool?.name == "Task") + } + + @Test("AskUserQuestion is a question even when the Feed overlay is lit") + func question() { + let hooks = fold([("pre-tool-use", #"{"tool_name":"AskUserQuestion","tool_input":{}}"#)]) + #expect(hooks.pendingQuestion) + #expect(hooks.openTool == nil) + let (activity, _) = classify(.init(registryState: .needsInput(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks, feedDecisionPending: true)) + #expect(activity.kind == .question) + let answered = fold([ + ("pre-tool-use", #"{"tool_name":"AskUserQuestion","tool_input":{}}"#), + ("post-tool-use", #"{"tool_name":"AskUserQuestion"}"#), + ]) + #expect(!answered.pendingQuestion) + } + + @Test("the Feed overlay without a hook question is a pending permission") + func permission() { + let (activity, safety) = classify(.init(registryState: .working(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: nil, feedDecisionPending: true)) + #expect(activity.kind == .permission) + #expect(safety.reasons == [.pendingPermission]) + } + + @Test("Stop with a running background task is background work and hides its process") + func backgroundWork() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"npm run dev"}}"#), + ("stop", #"{"background_tasks":[{"status":"running"}]}"#), + ]) + #expect(hooks.backgroundWork) + #expect(hooks.openTool == nil) + let (activity, safety) = classify(.init(registryState: .idle, registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks, foregroundCommand: "npm run dev")) + #expect(activity.kind == .background) + #expect(safety.safety == .care) + } + + @Test("an idle prompt notification after Stop is awaiting input") + func awaitingInput() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("stop", #"{}"#), + ("notification", #"{"notification_type":"idle_prompt"}"#), + ]) + let (activity, safety) = classify(.init(registryState: .needsInput(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks)) + #expect(activity.kind == .awaitingInput) + #expect(safety.safety == .safe) + } + + @Test("without hook facts the registry lifecycle still decides") + func registryOnly() { + let working = classify(.init(registryState: .working(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0)) + #expect(working.0.kind == .thinking) + #expect(working.0.since == t0) + let discovered = classify(.init(registryState: .idle, registryHasHookLifecycleState: false, + registryLastActivityAt: t0)) + #expect(discovered.0.kind == .unknown) + let ended = classify(.init(registryState: .ended, registryHasHookLifecycleState: true, registryLastActivityAt: t0)) + #expect(ended.0.kind == .ended) + } + + @Test("a foreground process decides when hooks are silent") + func processOnly() { + let (activity, safety) = classify(.init(registryState: .idle, registryHasHookLifecycleState: false, + registryLastActivityAt: t0, foregroundCommand: "go test ./...")) + #expect(activity.kind == .tool) + #expect(activity.source == .process) + #expect(safety.safety == .risky) + } + + @Test("a new session in the pane starts clean and session end ends it") + func sessionBoundaries() { + let hooks = fold([ + ("pre-tool-use", #"{"tool_name":"Bash","tool_input":{"command":"x"}}"#), + ("session-start", #"{}"#), + ]) + #expect(hooks == { + var fresh = AgentHookActivityState() + fresh.apply(.sessionStart, at: t0.addingTimeInterval(1)) + return fresh + }()) + let ended = fold([("prompt-submit", #"{}"#), ("session-end", #"{}"#)]) + #expect(ended.ended) + #expect(!ended.turnActive) + } + + @Test("parse keeps the session id and ignores unrelated subcommands") + func parse() { + let parsed = AgentHookActivityState.Event.parse( + subcommand: "post-tool-use", payload: Data(#"{"session_id":" abc ","tool_name":"Edit"}"#.utf8)) + #expect(parsed?.sessionID == "abc") + #expect(parsed?.event == .postToolUse(id: nil, toolName: "Edit")) + #expect(AgentHookActivityState.Event.parse(subcommand: "feed", payload: Data("{}".utf8)) == nil) + #expect(AgentHookActivityState.Event.parse(subcommand: "stop", payload: Data("[]".utf8)) == nil) + #expect(AgentHookActivityState.Event.parse(subcommand: "pre-tool-use", payload: Data("{}".utf8)) == nil) + } +} + +@Suite("Agent foreground command") +struct AgentForegroundCommandTests { + private typealias P = AgentForegroundCommand.Process + + private func census(_ processes: [P]) -> [Int: P] { + Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) + } + + @Test("an MCP server child is not a foreground command; a shell child is") + func shellChildOnly() { + let processes = census([ + P(pid: 10, parentPID: 1, name: "claude", isTerminalForeground: true), + P(pid: 11, parentPID: 10, name: "node", isTerminalForeground: true), + ]) + #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes) == nil) + let running = census([ + P(pid: 10, parentPID: 1, name: "claude", isTerminalForeground: true), + P(pid: 11, parentPID: 10, name: "node", isTerminalForeground: true), + P(pid: 12, parentPID: 10, name: "zsh", isTerminalForeground: true), + P(pid: 13, parentPID: 12, name: "swift-build", isTerminalForeground: true), + ]) + #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: running) == 13) + } + + @Test("background shells and shells older than the turn are ignored") + func filters() { + let turn = Date(timeIntervalSince1970: 500) + let processes = census([ + P(pid: 10, parentPID: 1, name: "claude", isTerminalForeground: true), + P(pid: 12, parentPID: 10, name: "zsh", isTerminalForeground: false), + P(pid: 14, parentPID: 10, name: "bash", isTerminalForeground: true, startedAt: Date(timeIntervalSince1970: 100)), + ]) + #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes, notBefore: turn) == nil) + #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes) == 14) + } + + @Test("describe shows a shell's script and truncates") + func describe() { + #expect(AgentForegroundCommand.describe(arguments: ["/bin/zsh", "-c", "-l", "npm test"]) == "npm test") + #expect(AgentForegroundCommand.describe(arguments: ["/usr/bin/make", "-j8", "all"]) == "make -j8 all") + #expect(AgentForegroundCommand.describe(arguments: []) == nil) + let long = AgentForegroundCommand.describe(arguments: ["x", String(repeating: "a", count: 300)]) + #expect(long?.count == AgentForegroundCommand.maximumLength) + } +} From be37f160821c078e53d8c88d10bbd989aa6a960f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:53:53 -0400 Subject: [PATCH 3/9] Report remote agents in current work by adapter and session state The current-work reducer built a Cloud or SSH agent row with the badge's report provenance (hook, plugin, detected) as its kind and the raw daemon state, so a blocked remote agent never raised needs_input attention. SurfaceAgentBadge now exposes agentIdentity (the adapter, shared with the sidebar slot key) and sessionState (blocked is needs_input, done is ended), and carries extra.agent_session_id from the cmux-tui catalog so the row gets its session id. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxTuiSnapshotParser.swift | 5 ++- .../RemoteAgentSidebarStatus.swift | 15 ++----- .../SurfaceAgentBadge+Identity.swift | 34 ++++++++++++++++ .../SurfaceCatalogModel.swift | 5 ++- .../SurfaceAgentBadgeIdentityTests.swift | 39 +++++++++++++++++++ Sources/Surfaces/CurrentWorkReducer.swift | 2 +- cmuxTests/CurrentWorkReducerTests.swift | 14 +++++++ 7 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceAgentBadge+Identity.swift create mode 100644 Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceAgentBadgeIdentityTests.swift diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CmuxTuiSnapshotParser.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CmuxTuiSnapshotParser.swift index 1946b80e43f8..f3a30d6ac696 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CmuxTuiSnapshotParser.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/CmuxTuiSnapshotParser.swift @@ -560,7 +560,7 @@ public struct CmuxTuiSnapshotParser: Sendable { lifecycle: SurfaceLifecycle(rawValue: terminal.lifecycle) ?? (terminal.running == true ? .running : .exited), agent: state.lookupIndex.agent(terminalID: terminal.id).map { - SurfaceAgentBadge(state: $0.state, source: $0.source, agent: $0.agent) + SurfaceAgentBadge(state: $0.state, source: $0.source, agent: $0.agent, agentSessionID: $0.agentSessionID) }, remoteWorkspace: nil, port: nil, @@ -1474,7 +1474,8 @@ public struct CmuxTuiSnapshotParser: Sendable { agent: (agent["extra"] as? [String: Any])?["agent"] as? String ?? (agent["agent"] as? String) ?? (agent["agent_type"] as? String) - ?? (agent["provider"] as? String) + ?? (agent["provider"] as? String), + agentSessionID: agentSessionID(from: agent) ) } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/RemoteAgentSidebarStatus.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/RemoteAgentSidebarStatus.swift index c64f66ecc507..a793a35dceaa 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/RemoteAgentSidebarStatus.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/RemoteAgentSidebarStatus.swift @@ -42,18 +42,11 @@ public struct RemoteAgentSidebarStatus: Hashable, Sendable { key.hasPrefix(statusKeyPrefix) } - /// The adapter identity (`claude`, `codex`, ...), never report provenance. + /// The sidebar slot name for the badge's adapter; Claude keeps its local `claude_code` key. static func agentKey(for badge: SurfaceAgentBadge) -> String { - let provenance: Set = ["hook", "socket", "detected", "plugin", "unknown"] - let identity = [badge.agent, badge.source] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .first { !$0.isEmpty && !provenance.contains($0) } - switch identity { - case "claude", "claude-code", "claude_code": return "claude_code" - case let identity?: - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) - let bounded = String(identity.unicodeScalars.filter(allowed.contains).prefix(64)) - return bounded.isEmpty ? "agent" : bounded + switch badge.agentIdentity { + case "claude": return "claude_code" + case let identity?: return identity case nil: return "agent" } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceAgentBadge+Identity.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceAgentBadge+Identity.swift new file mode 100644 index 000000000000..5500bee1499f --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceAgentBadge+Identity.swift @@ -0,0 +1,34 @@ +import Foundation + +extension SurfaceAgentBadge { + /// The adapter identity (`claude`, `codex`, ...), never report provenance + /// (`hook`, `socket`, `detected`, `plugin`). Claude's aliases read as `claude`, + /// the name local agent sessions use. Nil when only provenance is known. + public var agentIdentity: String? { + let provenance: Set = ["hook", "socket", "detected", "plugin", "unknown"] + let identity = [agent, source] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .first { !$0.isEmpty && !provenance.contains($0) } + switch identity { + case "claude", "claude-code", "claude_code": return "claude" + case let identity?: + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_.")) + let bounded = String(identity.unicodeScalars.filter(allowed.contains).prefix(64)) + return bounded.isEmpty ? nil : bounded + case nil: return nil + } + } + + /// The daemon state in the local agent-session vocabulary: cmux-tui + /// `blocked` is `needs_input` and `done` is `ended`. Other states pass through. + public var sessionState: String { + switch state.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "blocked", "needs_input": return "needs_input" + case "done", "ended": return "ended" + case "working": return "working" + case "idle": return "idle" + case "unknown", "": return "unknown" + default: return state + } + } +} diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogModel.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogModel.swift index 9490caa4bf00..2ab0450873b1 100644 --- a/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogModel.swift +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Sources/CmuxSurfaceCatalogModel/SurfaceCatalogModel.swift @@ -75,11 +75,14 @@ public struct SurfaceAgentBadge: Hashable, Codable, Sendable { public var source: String? /// The adapter identity, separate from report provenance (`hook`, `socket`, or `plugin`). public var agent: String? = nil + /// The agent's own session id (`extra.agent_session_id`), when reported. + public var agentSessionID: String? = nil - public init(state: String, source: String? = nil, agent: String? = nil) { + public init(state: String, source: String? = nil, agent: String? = nil, agentSessionID: String? = nil) { self.state = state self.source = source self.agent = agent + self.agentSessionID = agentSessionID } } diff --git a/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceAgentBadgeIdentityTests.swift b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceAgentBadgeIdentityTests.swift new file mode 100644 index 000000000000..4d6acf0ee21a --- /dev/null +++ b/Packages/macOS/CmuxSurfaceCatalogModel/Tests/CmuxSurfaceCatalogModelTests/SurfaceAgentBadgeIdentityTests.swift @@ -0,0 +1,39 @@ +import CmuxSurfaceCatalogModel +import Testing + +@Suite struct SurfaceAgentBadgeIdentityTests { + @Test func identityIsTheAdapterNotTheProvenance() { + #expect(SurfaceAgentBadge(state: "working", source: "hook", agent: "claude").agentIdentity == "claude") + #expect(SurfaceAgentBadge(state: "working", source: "claude-code").agentIdentity == "claude") + #expect(SurfaceAgentBadge(state: "working", source: "plugin", agent: " OpenCode ").agentIdentity == "opencode") + #expect(SurfaceAgentBadge(state: "working", source: "hook").agentIdentity == nil) + #expect(SurfaceAgentBadge(state: "working", source: "hook", agent: ";;").agentIdentity == nil) + } + + @Test func daemonStatesUseTheAgentSessionVocabulary() { + #expect(SurfaceAgentBadge(state: "blocked").sessionState == "needs_input") + #expect(SurfaceAgentBadge(state: "done").sessionState == "ended") + #expect(SurfaceAgentBadge(state: "Working").sessionState == "working") + #expect(SurfaceAgentBadge(state: "idle").sessionState == "idle") + #expect(SurfaceAgentBadge(state: "unknown").sessionState == "unknown") + #expect(SurfaceAgentBadge(state: "compacting").sessionState == "compacting") + } + + @Test func catalogTerminalsCarryTheAgentSessionID() { + let snapshot: [String: Any] = [ + "terminals": [["id": "term_1", "title": "claude", "running": true]], + "agents": [[ + "id": "agent_1", + "session_id": "mux-session", + "terminal_id": "term_1", + "state": "blocked", + "source": "hook", + "extra": ["agent": "claude", "agent_session_id": "claude-session-a"], + ]], + ] + let badge = CmuxTuiSnapshotParser.terminals(fromSnapshot: snapshot, machine: .ssh("host")).first?.agent + #expect(badge?.agentSessionID == "claude-session-a") + #expect(badge?.agentIdentity == "claude") + #expect(badge?.sessionState == "needs_input") + } +} diff --git a/Sources/Surfaces/CurrentWorkReducer.swift b/Sources/Surfaces/CurrentWorkReducer.swift index 1918537d0425..d53d9100dea9 100644 --- a/Sources/Surfaces/CurrentWorkReducer.swift +++ b/Sources/Surfaces/CurrentWorkReducer.swift @@ -61,7 +61,7 @@ struct CurrentWorkReducer { } } if let badge = resource.agent, agents.isEmpty { - agents.append(.init(sessionID: nil, kind: badge.source, state: bounded(badge.state), hasHookLifecycleState: false, + agents.append(.init(sessionID: badge.agentSessionID, kind: badge.agentIdentity, state: bounded(badge.sessionState), hasHookLifecycleState: false, version: nil, lastActivityAt: nil, evidence: .init(owner: "SurfaceCatalog", reference: resource.id.rawValue, observedAt: observedAt))) } let boundedAgents = Array(agents.prefix(8)) diff --git a/cmuxTests/CurrentWorkReducerTests.swift b/cmuxTests/CurrentWorkReducerTests.swift index 54e92cf7daba..255812e6ba2e 100644 --- a/cmuxTests/CurrentWorkReducerTests.swift +++ b/cmuxTests/CurrentWorkReducerTests.swift @@ -134,6 +134,20 @@ struct CurrentWorkReducerTests { #expect(item.omitted["projections"] == 3) } + @Test("A remote agent badge reports its adapter, session and needs-input state") + func remoteAgentBadge() throws { + var input = fixture(machine: .cloud("test-machine")) + input.export.catalog.resources[0].agent = SurfaceAgentBadge( + state: "blocked", source: "hook", agent: "claude", agentSessionID: "claude-session-a" + ) + let item = try #require(CurrentWorkReducer().reduce(input).items.first) + let agent = try #require(item.agents.first) + #expect(agent.kind == "claude") + #expect(agent.sessionID == "claude-session-a") + #expect(agent.state == "needs_input") + #expect(item.attention.contains { $0.kind == "needs_input" && $0.scope == "agent" }) + } + @Test("Oversized identifiers fail encoding rather than being silently changed") func encodedByteLimit() throws { var input = fixture() From 335ff68b6207eb02d229799588d1f994fc140f29 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 05:54:12 -0400 Subject: [PATCH 4/9] Add agent.list: live agent panes with activity and resume safety AgentActivityIndex joins the agent session registry to live workspace and dock panels in one main-actor turn, adds each session's hook turn facts, the Feed decision overlay and, for local panes, a foreground command from one cached process census, then classifies. Each entry carries workspace, panel, surface and pane ids, name, agent, session, pid, placement (local, ssh with its host, cloud), survives_app_relaunch, activity and resume_safety. Claude now installs an unmatched queued PostToolUse hook. agent.hook.enqueue records every admitted hook in AgentHookActivityTracker before queueing, and a Claude post-tool-use only closes the open call: no hook process runs for it. agent.list is a worker-lane read advertised in capabilities. It has no relay contract because it returns local argv, and a test pins that. Refs #15266 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+ClaudeHookSettings.swift | 6 + CLI/cmux.swift | 9 +- .../Model/AgentHookActivityState.swift | 12 +- .../Model/AgentPanePlacement.swift | 31 +++ .../AgentActivityEvidenceTests.swift | 24 +++ .../AgentHookDeliveryPolicy.swift | 2 +- .../AgentHookDeliveryPolicyTests.swift | 3 +- .../Wire/ControlCommandExecutionPolicy.swift | 3 + .../ControlCommandExecutionPolicyTests.swift | 6 + .../RemoteCLIRelayPolicyTests.swift | 8 + Resources/bin/cmux-claude-wrapper | 5 +- Sources/AgentHookDeliveryEvent.swift | 6 + Sources/Agents/AgentActivityIndex.swift | 196 ++++++++++++++++++ Sources/Agents/AgentHookActivityTracker.swift | 65 ++++++ .../Agents/TerminalController+AgentList.swift | 64 ++++++ Sources/TerminalController+Capabilities.swift | 1 + Sources/TerminalController.swift | 6 + cmux.xcodeproj/project.pbxproj | 12 ++ .../CLIClaudeHookTimeoutRegressionTests.swift | 1 + tests/test_claude_wrapper_hooks.py | 5 +- 20 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentPanePlacement.swift create mode 100644 Sources/Agents/AgentActivityIndex.swift create mode 100644 Sources/Agents/AgentHookActivityTracker.swift create mode 100644 Sources/Agents/TerminalController+AgentList.swift diff --git a/CLI/CMUXCLI+ClaudeHookSettings.swift b/CLI/CMUXCLI+ClaudeHookSettings.swift index 3e5213ececbc..89a9f697a10c 100644 --- a/CLI/CMUXCLI+ClaudeHookSettings.swift +++ b/CLI/CMUXCLI+ClaudeHookSettings.swift @@ -58,6 +58,12 @@ extension CMUXCLI { matcher: "PushNotification", subcommand: "push-notification" ), + // Closes the call PreToolUse opened, so the app can tell a running + // tool from a finished one. The app records it at admission and + // starts no hook process for it. + Self.claudeQueuedHookGroup( + subcommand: "post-tool-use" + ), ] hooks["PermissionRequest"] = [ Self.claudeHookGroup( diff --git a/CLI/cmux.swift b/CLI/cmux.swift index fa5dda56c28a..75fd48c89f2b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28883,6 +28883,13 @@ struct CMUXCLI { pendingWork: notifyPending), client: client) } printClaudeHookAck() + case "post-tool-use": + // The app records tool completion when it admits this event and + // starts no delivery process for it; this ack covers a direct call. + telemetry.breadcrumb("claude-hook.post-tool-use") + didSendFeedTelemetry = true + printClaudeHookAck() + case "push-notification": try runClaudePushNotificationHook(client: client, telemetry: telemetry, parsedInput: parsedInput, sessionStore: sessionStore, routing: hookRouting, markFeedTelemetryHandled: { didSendFeedTelemetry = true }, sendFeedTelemetry: sendClaudeFeedTelemetry) case "session-end": telemetry.breadcrumb("claude-hook.session-end") @@ -29268,7 +29275,7 @@ struct CMUXCLI { telemetry.breadcrumb("claude-hook.help") print( """ - cmux claude-hook [--workspace ] [--surface ] + cmux claude-hook [--workspace ] [--surface ] """ ) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift index 97275b215baa..a0e7bac7a272 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift @@ -38,6 +38,8 @@ public struct AgentHookActivityState: Sendable, Equatable { public private(set) var ended = false /// When the latest transition happened. public private(set) var since: Date? + /// When the running turn's prompt was submitted; nil between turns. + public private(set) var turnStartedAt: Date? private var openTools: [OpenTool] = [] public init() {} @@ -57,6 +59,7 @@ public struct AgentHookActivityState: Sendable, Equatable { since = date case .promptSubmit: turnActive = true + turnStartedAt = date lastToolFinished = false backgroundWork = false awaitingInput = false @@ -90,6 +93,7 @@ public struct AgentHookActivityState: Sendable, Equatable { } case .stop(let background): turnActive = false + turnStartedAt = nil lastToolFinished = false pendingQuestion = false awaitingInput = false @@ -108,6 +112,11 @@ public struct AgentHookActivityState: Sendable, Equatable { } extension AgentHookActivityState.Event { + /// The hook subcommands that carry an activity fact. + public static let subcommands: Set = [ + "session-start", "prompt-submit", "pre-tool-use", "post-tool-use", "stop", "notification", "session-end", + ] + /// Longest command or summary kept for an open tool. public static let maximumCommandLength = 200 @@ -116,7 +125,8 @@ extension AgentHookActivityState.Event { /// - Returns: The event and the hook's session id, or nil when the /// subcommand carries no activity fact or the payload is not a JSON object. public static func parse(subcommand: String, payload: Data) -> (event: Self, sessionID: String?)? { - guard let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else { + guard subcommands.contains(subcommand), + let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else { return nil } let sessionID = string(object, ["session_id", "sessionId"]) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentPanePlacement.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentPanePlacement.swift new file mode 100644 index 000000000000..878151016cca --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentPanePlacement.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Where an agent pane's process runs. +public enum AgentPanePlacement: Sendable, Equatable { + /// On this Mac. + case local + /// On a `cmux ssh` host, kept alive by its remote daemon. + case ssh(host: String?) + /// On a cmux Cloud machine. + case cloud + + /// Whether the agent keeps running when this app quits or relaunches. + /// A separate fact from resume safety; each consumer decides what it means. + public var survivesAppRelaunch: Bool { + self != .local + } + + /// The wire name: `local`, `ssh` or `cloud`. + public var kind: String { + switch self { + case .local: "local" + case .ssh: "ssh" + case .cloud: "cloud" + } + } + + public var host: String? { + if case .ssh(let host) = self { return host } + return nil + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift index 0fd2099fbaab..a9da02fdcce6 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift @@ -222,3 +222,27 @@ struct AgentForegroundCommandTests { #expect(long?.count == AgentForegroundCommand.maximumLength) } } + +@Suite("Agent pane placement") +struct AgentPanePlacementTests { + @Test("only local panes stop with the app") + func survivesAppRelaunch() { + #expect(!AgentPanePlacement.local.survivesAppRelaunch) + #expect(AgentPanePlacement.ssh(host: "box").survivesAppRelaunch) + #expect(AgentPanePlacement.cloud.survivesAppRelaunch) + #expect(AgentPanePlacement.ssh(host: "box").kind == "ssh") + #expect(AgentPanePlacement.ssh(host: "box").host == "box") + #expect(AgentPanePlacement.cloud.host == nil) + } + + @Test("the turn start is kept until Stop") + func turnStart() { + var state = AgentHookActivityState() + let start = Date(timeIntervalSince1970: 10) + state.apply(.promptSubmit, at: start) + state.apply(.preToolUse(id: nil, tool: .init(name: "Bash")), at: start.addingTimeInterval(5)) + #expect(state.turnStartedAt == start) + state.apply(.stop(backgroundWork: false), at: start.addingTimeInterval(9)) + #expect(state.turnStartedAt == nil) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift index 24524ebc1f7a..d3a876b8ec02 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentHookDeliveryPolicy.swift @@ -49,7 +49,7 @@ public struct AgentHookDeliveryPolicy: Sendable { private static let auxiliaryQueuedSubcommands: [String: Set] = [ "amp": ["title-update", "lifecycle"], - "claude": ["pre-tool-use", "push-notification", "feed"], + "claude": ["pre-tool-use", "post-tool-use", "push-notification", "feed"], "codex": ["pre-tool-use", "post-tool-use"], // OMP and Pi run subagents headless inside the parent's process, so a // child has no live bound process of its own. These lifecycle-only diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift index 300b23892be7..1c615c0191f0 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentHookDeliveryPolicyTests.swift @@ -21,7 +21,8 @@ struct AgentHookDeliveryPolicyTests { #expect(policy.supportsQueuedDelivery(agent: "claude", subcommand: "pre-tool-use")) #expect(policy.supportsQueuedDelivery(agent: "codex", subcommand: "pre-tool-use")) #expect(policy.supportsQueuedDelivery(agent: "codex", subcommand: "post-tool-use")) - #expect(!policy.supportsQueuedDelivery(agent: "claude", subcommand: "post-tool-use")) + #expect(policy.supportsQueuedDelivery(agent: "claude", subcommand: "post-tool-use")) + #expect(!policy.supportsQueuedDelivery(agent: "future-agent", subcommand: "post-tool-use")) #expect(!policy.supportsQueuedDelivery( agent: String(repeating: "a", count: 129), subcommand: "session-start" diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 4e73bdd3fefe..21e9891f6168 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -165,6 +165,9 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "surface.catalog", // Current-work captures owners once, then reduces/encodes off-main without refresh. "current.list", + // Agent activity joins the session registry on the main actor once, + // then awaits a process census and encodes off-main. + "agent.list", "surface.project", "surface.new_terminal", // SSH-session attach resolves ownership and reads the remote PTY diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index d126aadaffe5..59b9d92e2552 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -187,6 +187,12 @@ struct ControlCommandExecutionPolicyTests { #expect(ControlCommandExecutionPolicy(forMethod: "vm.create") == .socketWorker(mainThreadCallable: false)) } + @Test func agentListRunsOnTheWorkerAndIsNotMainThreadCallable() { + // agent.list awaits a process census; a main-thread caller would block on it. + #expect(ControlCommandExecutionPolicy(forMethod: "agent.list") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "current.list") == .socketWorker(mainThreadCallable: false)) + } + @Test func terminalReadsRunOnTheWorkerAndAreNotMainThreadCallable() { // Tranche C (issue #5757): the Ghostty capture is one v2MainSync hop, // the (possibly multi-MB) scrollback formatting runs on the worker. diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift index 42eab951ad57..2ec09c704a15 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift @@ -173,6 +173,14 @@ struct RemoteCLIRelayPolicyTests { ) } + /// `agent.list` returns local process argv and every local agent pane, so a + /// remote workspace must never discover or call it. + @Test("agent.list has no relay contract") + func agentListHasNoRelayContract() { + #expect(RemoteRelayRoutingSchema().parameters(for: "agent.list") == nil) + #expect(RemoteRelayCommandPolicy().permittedMethods(from: ["agent.list"]).isEmpty) + } + @Test("workspace.reorder is denied through a relay", arguments: [ #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","index":0}}"#, #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","before_workspace_id":"1EA7D9C4-0000-4000-8000-00000000A002"}}"#, diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 458e15de4b80..7290285d2da5 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -1939,6 +1939,7 @@ CMUX_CLAUDE_QUEUED_HOOK_COMMAND local notification="${queued//@SUBCOMMAND@/notification}" local push_notification="${queued//@SUBCOMMAND@/push-notification}" local pre_tool_use="${queued//@SUBCOMMAND@/pre-tool-use}" + local post_tool_use="${queued//@SUBCOMMAND@/post-tool-use}" local session_end="${queued//@SUBCOMMAND@/session-end}" local session_start="${queued//@SUBCOMMAND@/session-start}" local stop="${queued//@SUBCOMMAND@/stop}" @@ -1948,7 +1949,7 @@ CMUX_CLAUDE_QUEUED_HOOK_COMMAND cmux_claude_standard_hook_settings_value='{"hooks":{' cmux_claude_standard_hook_settings_value+='"Notification":['"$q_prefix$notification$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"PermissionRequest":[{"hooks":[{"command":"'"$direct_cli"' hooks feed --source claude","timeout":125,"type":"command"}],"matcher":""}],' - cmux_claude_standard_hook_settings_value+='"PostToolUse":['"$q_prefix$push_notification$q_suffix"'PushNotification"}],' + cmux_claude_standard_hook_settings_value+='"PostToolUse":['"$q_prefix$push_notification$q_suffix"'PushNotification"},'"$q_prefix$post_tool_use$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"PreToolUse":[{"hooks":[{"command":"'"$direct_cli"' hooks claude cron-create-guard","timeout":5,"type":"command"}],"matcher":"CronCreate"},'"$q_prefix$pre_tool_use$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"SessionEnd":['"$q_prefix$session_end$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"SessionStart":['"$q_prefix$session_start$q_suffix"'"}],' @@ -2049,6 +2050,8 @@ catch (_) { process.exit(1); } # bridge is the only way its message reaches the user inside cmux. ASYNC; # the handler mirrors the tool's own sent/skipped decision via # tool_response.localSent. +# - PostToolUse (every tool): queued; closes the call its PreToolUse opened +# so `agent.list` can tell a running tool from a finished one. # - PermissionRequest: cmux hooks feed. SYNC with a 125s timeout. # This is Claude Code's native blocking decision hook. Outside # bypassPermissions it covers permissions, ExitPlanMode, and AskUserQuestion diff --git a/Sources/AgentHookDeliveryEvent.swift b/Sources/AgentHookDeliveryEvent.swift index 4b67af682ca3..b584170d0e42 100644 --- a/Sources/AgentHookDeliveryEvent.swift +++ b/Sources/AgentHookDeliveryEvent.swift @@ -70,6 +70,12 @@ struct AgentHookDeliveryEvent: Sendable { } } + /// Claude's PostToolUse exists only to close the call its PreToolUse opened + /// in ``AgentHookActivityTracker``; no hook process runs for it. + var isActivityRecordOnly: Bool { + agent == "claude" && subcommand == "post-tool-use" + } + /// High-volume telemetry may use the replaceable ingress reservation, but /// tool events that can surface Needs input remain protected with lifecycle /// transitions and notifications. diff --git a/Sources/Agents/AgentActivityIndex.swift b/Sources/Agents/AgentActivityIndex.swift new file mode 100644 index 000000000000..c874bf7492d3 --- /dev/null +++ b/Sources/Agents/AgentActivityIndex.swift @@ -0,0 +1,196 @@ +import CmuxAgentChat +import CmuxMobileHost +import Foundation + +/// One live agent pane with what it is doing and whether a restart can interrupt it. +struct AgentActivitySnapshot: Sendable { + var workspaceID: UUID + /// The workspace panel hosting the agent. Equal to ``surfaceID`` for + /// ordinary terminals; kept separate so consumers need not assume that. + var panelID: UUID + /// The terminal surface the agent's hooks report (`CMUX_SURFACE_ID`). + var surfaceID: UUID + /// The split pane holding the panel, when it is in the split tree. + var paneID: UUID? + var name: String? + var agentKind: String + var sessionID: String + var pid: Int? + var placement: AgentPanePlacement + var activity: AgentActivity + var assessment: ResumeSafetyAssessment + + var survivesAppRelaunch: Bool { placement.survivesAppRelaunch } +} + +/// Builds ``AgentActivitySnapshot``s from state the app already owns: the agent +/// session registry, hook turn facts, the Feed's decision overlay and, for local +/// panes, one bounded process census. Reading never refreshes or mutates anything. +@MainActor +struct AgentActivityIndex { + /// How old a shared process census may be. + nonisolated static let processCensusMaximumAge: TimeInterval = 2 + + private let agentRecords: @MainActor () -> [AgentChatSessionRecord]? + private let workspaceOwners: @MainActor (Set) -> [UUID: Workspace] + private let hookActivity: AgentHookActivityTracker + private let processCensus: @Sendable () async -> CmuxTopProcessSnapshot + + init( + agentRecords: @escaping @MainActor () -> [AgentChatSessionRecord]?, + workspaceOwners: @escaping @MainActor (Set) -> [UUID: Workspace], + hookActivity: AgentHookActivityTracker = .shared, + processCensus: @escaping @Sendable () async -> CmuxTopProcessSnapshot = { + await CmuxTopProcessSnapshot.captureCached( + includeCMUXScope: false, + includeResources: false, + maximumAge: AgentActivityIndex.processCensusMaximumAge + ) + } + ) { + self.agentRecords = agentRecords + self.workspaceOwners = workspaceOwners + self.hookActivity = hookActivity + self.processCensus = processCensus + } + + /// One entry per live agent pane, most recently active first. + func snapshot() async -> [AgentActivitySnapshot] { + let panes = capture() + let probes = panes.enumerated().compactMap { index, pane -> ForegroundProbe? in + guard pane.placement == .local, let pid = pane.pid, pid > 0 else { return nil } + return ForegroundProbe(index: index, agentPID: pid, notBefore: pane.evidence.hooks?.turnStartedAt) + } + let commands = probes.isEmpty ? [:] : await Self.foregroundCommands(probes, census: processCensus) + return panes.enumerated().map { index, pane in + var evidence = pane.evidence + evidence.foregroundCommand = commands[index] + let result = AgentActivityClassifier.classify(evidence.signals) + return AgentActivitySnapshot( + workspaceID: pane.workspaceID, panelID: pane.panelID, surfaceID: pane.panelID, + paneID: pane.paneID, name: pane.name, agentKind: pane.agentKind, sessionID: pane.sessionID, + pid: pane.pid, placement: pane.placement, + activity: result.activity, assessment: result.safety + ) + } + } + + private struct Pane { + var workspaceID: UUID + var panelID: UUID + var paneID: UUID? + var name: String? + var agentKind: String + var sessionID: String + var pid: Int? + var placement: AgentPanePlacement + var evidence: AgentActivityEvidence + } + + private struct ForegroundProbe: Sendable { + var index: Int + var agentPID: Int + var notBefore: Date? + } + + /// Joins registry records to live panels in one main-actor turn. + private func capture() -> [Pane] { + guard let records = agentRecords() else { return [] } + var bound: [(record: AgentChatSessionRecord, workspaceID: UUID, panelID: UUID)] = [] + var seenPanels: Set = [] + // Records arrive most recent first; the newest live session owns its pane. + for record in records { + if case .ended = record.state { continue } + guard let rawWorkspace = record.workspaceID, let workspaceID = UUID(uuidString: rawWorkspace), + let rawSurface = record.surfaceID, let panelID = UUID(uuidString: rawSurface), + seenPanels.insert(panelID).inserted else { continue } + bound.append((record, workspaceID, panelID)) + } + let owners = workspaceOwners(Set(bound.map(\.workspaceID))) + return bound.compactMap { record, workspaceID, panelID in + guard let workspace = owners[workspaceID] else { return nil } + let dock = workspace.panels[panelID] == nil ? workspace._dockSplit : nil + guard let panel = workspace.panels[panelID] ?? dock?.panels[panelID] else { return nil } + let lifecycles = dock?.agentRuntimeByPanelId[panelID]?.agentLifecycleStates + ?? workspace.agentLifecycleStatesByPanelId[panelID] ?? [:] + let feedKey = FeedCoordinator.attentionStatusKey(forSource: record.agentKind.sourceName) + let placement = Self.placement( + workspace: workspace, + panel: panel, + dockRemote: dock?.terminalLinkIsRemoteTerminal(panelID) ?? false + ) + let hooks = hookActivity.state( + surfaceID: panelID, + sessionIDs: [record.sessionID] + [record.hookStoreSessionID].compactMap { $0 } + ) + let paneID = dock == nil ? workspace.paneId(forPanelId: panelID)?.id : dock?.paneId(forPanelId: panelID)?.id + let title = dock == nil ? workspace.panelTitle(panelId: panelID) : nil + return Pane( + workspaceID: workspaceID, panelID: panelID, paneID: paneID, + name: Self.nonEmpty(title) ?? Self.nonEmpty(record.title), + agentKind: record.agentKind.sourceName, sessionID: record.sessionID, + pid: record.pid, placement: placement, + evidence: AgentActivityEvidence( + registryState: record.state, + registryHasHookLifecycleState: record.hasHookLifecycleState, + registryLastActivityAt: record.lastActivityAt, + hooks: hooks, + feedDecisionPending: lifecycles[feedKey] == .needsInput + ) + ) + } + } + + private static func placement(workspace: Workspace, panel: any Panel, dockRemote: Bool) -> AgentPanePlacement { + if (panel as? TerminalPanel)?.cloudAttachment != nil + || workspace.remoteConfiguration?.managedCloudVMID != nil { + return .cloud + } + if let configuration = workspace.remoteConfiguration { + return .ssh(host: configuration.destination) + } + if workspace.isRemoteTmuxMirror || dockRemote { + return .ssh(host: nil) + } + return .local + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } + return trimmed + } + + /// Reads one census off the main actor. An unavailable census leaves every + /// command unknown rather than proving none runs. + private nonisolated static func foregroundCommands( + _ probes: [ForegroundProbe], + census: @Sendable () async -> CmuxTopProcessSnapshot + ) async -> [Int: String] { + let snapshot = await census() + guard snapshot.captureIsAvailable else { return [:] } + var commands: [Int: String] = [:] + for probe in probes { + var processes: [Int: AgentForegroundCommand.Process] = [:] + for pid in snapshot.descendantPIDs(rootPID: probe.agentPID, includeRoot: true) { + guard let process = snapshot.process(pid: pid) else { continue } + processes[pid] = AgentForegroundCommand.Process( + pid: pid, + parentPID: process.parentPID, + name: process.name, + isTerminalForeground: process.isTerminalForegroundProcessGroup, + startedAt: process.processIdentity.map { + Date(timeIntervalSince1970: TimeInterval($0.startSeconds) + TimeInterval($0.startMicroseconds) / 1_000_000) + } + ) + } + guard let pid = AgentForegroundCommand.commandPID( + agentPID: probe.agentPID, processes: processes, notBefore: probe.notBefore + ), let process = snapshot.process(pid: pid), + let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments else { + continue + } + commands[probe.index] = AgentForegroundCommand.describe(arguments: arguments) + } + return commands + } +} diff --git a/Sources/Agents/AgentHookActivityTracker.swift b/Sources/Agents/AgentHookActivityTracker.swift new file mode 100644 index 000000000000..ed5b68d25afb --- /dev/null +++ b/Sources/Agents/AgentHookActivityTracker.swift @@ -0,0 +1,65 @@ +import CmuxAgentChat +import Foundation + +/// Folds admitted agent hooks into per-pane, per-session turn facts for `agent.list`. +/// +/// `agent.hook.enqueue` records every event here before queue admission, so a +/// best-effort tool event the delivery queue later coalesces still counts. +/// Recording parses at most one bounded payload and takes one short lock. +final class AgentHookActivityTracker: @unchecked Sendable { + static let shared = AgentHookActivityTracker() + + /// Sessions kept across all panes; the least recently updated are dropped first. + static let maximumSessions = 512 + + private struct Key: Hashable { + var surfaceID: UUID + var sessionID: String + } + + private let lock = NSLock() + private var states: [Key: AgentHookActivityState] = [:] + + init() {} + + /// Records one hook event. Events without a pane or session id are ignored. + func record(_ event: AgentHookDeliveryEvent, at date: Date = Date()) { + guard let rawSurfaceID = event.environment["CMUX_SURFACE_ID"], + let surfaceID = UUID(uuidString: rawSurfaceID), + let parsed = AgentHookActivityState.Event.parse( + subcommand: event.subcommand, + payload: Data(event.payload.utf8) + ), + let sessionID = parsed.sessionID ?? event.sessionID else { + return + } + record(parsed.event, surfaceID: surfaceID, sessionID: sessionID, at: date) + } + + func record(_ event: AgentHookActivityState.Event, surfaceID: UUID, sessionID: String, at date: Date) { + let key = Key(surfaceID: surfaceID, sessionID: sessionID) + lock.lock() + defer { lock.unlock() } + var state = states[key] ?? AgentHookActivityState() + state.apply(event, at: date) + states[key] = state + if states.count > Self.maximumSessions { + let overflow = states.count - Self.maximumSessions + let oldest = states.sorted { ($0.value.since ?? .distantPast) < ($1.value.since ?? .distantPast) } + .prefix(overflow) + .map(\.key) + for key in oldest { states.removeValue(forKey: key) } + } + } + + /// The facts for one session in one pane. `sessionIDs` lists every id the + /// registry knows the session by; the first recorded one wins. + func state(surfaceID: UUID, sessionIDs: [String]) -> AgentHookActivityState? { + lock.lock() + defer { lock.unlock() } + for sessionID in sessionIDs { + if let state = states[Key(surfaceID: surfaceID, sessionID: sessionID)] { return state } + } + return nil + } +} diff --git a/Sources/Agents/TerminalController+AgentList.swift b/Sources/Agents/TerminalController+AgentList.swift new file mode 100644 index 000000000000..5b2b74ecb507 --- /dev/null +++ b/Sources/Agents/TerminalController+AgentList.swift @@ -0,0 +1,64 @@ +import CmuxAgentChat +import Foundation + +extension TerminalController { + /// `agent.list`: every live agent pane with its activity and resume safety. + /// + /// A worker-lane read. One main-actor turn joins the session registry to live + /// panels; the process census and encoding run off the main actor. Local-only: + /// the payload carries local process argv, so the remote relay never forwards it. + nonisolated func socketWorkerAgentListResponse(id: Any?, params: [String: Any]) -> String { + guard params.isEmpty else { + return v2Error(id: id, code: "invalid_params", message: "agent.list takes no parameters") + } + return v2VmCall(id: id, timeoutSeconds: 10) { + let agents = await Self.captureAgentActivity() + return ["agents": agents.map(Self.agentListPayload)] + } + } + + @MainActor + private static func captureAgentActivity() async -> [AgentActivitySnapshot] { + await AgentActivityIndex( + agentRecords: { TerminalController.shared.agentChatTranscriptService?.sessionRecords(workspaceID: nil) }, + workspaceOwners: { AppDelegate.shared?.workspacesForRead(tabIds: $0) ?? [:] } + ).snapshot() + } + + nonisolated static func agentListPayload(_ agent: AgentActivitySnapshot) -> [String: Any] { + func orNull(_ value: Any?) -> Any { value ?? NSNull() } + func timestamp(_ date: Date?) -> Any { orNull(date?.ISO8601Format()) } + var activity: [String: Any] = [ + "kind": agent.activity.kind.rawValue, + "since": timestamp(agent.activity.since), + "source": agent.activity.source.rawValue, + ] + if let tool = agent.activity.tool { + activity["tool"] = [ + "name": tool.name, + "command": orNull(tool.command), + "started_at": timestamp(tool.startedAt), + ] as [String: Any] + } + return [ + "workspace_id": agent.workspaceID.uuidString, + "panel_id": agent.panelID.uuidString, + "surface_id": agent.surfaceID.uuidString, + "pane_id": orNull(agent.paneID?.uuidString), + "name": orNull(agent.name), + "agent": agent.agentKind, + "session_id": agent.sessionID, + "pid": orNull(agent.pid), + "placement": [ + "kind": agent.placement.kind, + "host": orNull(agent.placement.host), + ] as [String: Any], + "survives_app_relaunch": agent.survivesAppRelaunch, + "activity": activity, + "resume_safety": [ + "safety": agent.assessment.safety.rawValue, + "reasons": agent.assessment.reasons.map(\.rawValue), + ] as [String: Any], + ] + } +} diff --git a/Sources/TerminalController+Capabilities.swift b/Sources/TerminalController+Capabilities.swift index d73cad2a7e34..58cf5ddae8a2 100644 --- a/Sources/TerminalController+Capabilities.swift +++ b/Sources/TerminalController+Capabilities.swift @@ -153,6 +153,7 @@ extension TerminalController { "vm.tunnel_wait", "surface.catalog", "current.list", + "agent.list", "surface.project", "surface.new_terminal", "aiAccounts.list", diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7aaec5f51617..3ad7873dad58 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1703,6 +1703,10 @@ class TerminalController { ) ) } + AgentHookActivityTracker.shared.record(event) + if event.isActivityRecordOnly { + return v2Ok(id: request.id, result: ["queued": true]) + } guard agentHookDeliveryQueue.enqueue(event) else { return v2Error( id: request.id, @@ -2074,6 +2078,8 @@ class TerminalController { #endif case "current.list": return socketWorkerCurrentWorkResponse(id: request.id, params: request.params) + case "agent.list": + return socketWorkerAgentListResponse(id: request.id, params: request.params) case "surface.catalog", "surface.project", "surface.new_terminal": return socketWorkerSurfaceResponse(method: request.method, id: request.id, params: request.params) case let method where method.hasPrefix("vm."): diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index efce2ebc7e48..ab2413f6b033 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ D0B102010000000000000001 /* AcknowledgmentsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B103010000000000000001 /* AcknowledgmentsWindowController.swift */; }; A9E020000000000000000006 /* agent-session-react in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000006 /* agent-session-react */; }; A9E020000000000000000007 /* agent-session-solid in Resources */ = {isa = PBXBuildFile; fileRef = A9E010000000000000000007 /* agent-session-solid */; }; + CF6691E746957D8EAA5E1131 /* AgentActivityIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1547E2C6E5DE666CAE3B77F8 /* AgentActivityIndex.swift */; }; 5FAC71D5AC71D5AC71D50001 /* AgentChatChildRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FAC71D5AC71D5AC71D50002 /* AgentChatChildRunTests.swift */; }; C7A52E000000000000000002 /* AgentChatEndedTranscriptListabilityCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */; }; C7A53F000000000000000002 /* AgentChatFallbackTranscriptResolutionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A53F000000000000000001 /* AgentChatFallbackTranscriptResolutionCoordinator.swift */; }; @@ -122,6 +123,7 @@ F65760200000000000000001 /* AgentHibernationTranscriptHookStoreFileMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760200000000000000002 /* AgentHibernationTranscriptHookStoreFileMirror.swift */; }; F65760210000000000000001 /* AgentHibernationTranscriptHookStoreRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760210000000000000002 /* AgentHibernationTranscriptHookStoreRecord.swift */; }; F65760250000000000000001 /* AgentHibernationTranscriptSnapshotRaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65760250000000000000002 /* AgentHibernationTranscriptSnapshotRaceTests.swift */; }; + D0228AE2BD13B63BCBD94780 /* AgentHookActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CAA8C3B254E024F4749E2E /* AgentHookActivityTracker.swift */; }; A61E00010000000000000001 /* AgentHookDeliveryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61E00010000000000000003 /* AgentHookDeliveryEvent.swift */; }; A61E00010000000000000005 /* AgentHookDeliveryProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61E00010000000000000006 /* AgentHookDeliveryProcess.swift */; }; A61E00010000000000000002 /* AgentHookDeliveryQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61E00010000000000000004 /* AgentHookDeliveryQueue.swift */; }; @@ -3369,6 +3371,7 @@ F87920000000000000000005 /* TerminalConfigurationReloadPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87920000000000000000006 /* TerminalConfigurationReloadPhase.swift */; }; A10557050000000000000001 /* TerminalConfigurationSurfaceApplyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10557050000000000000002 /* TerminalConfigurationSurfaceApplyState.swift */; }; A77A0007A1B2C3D4E5F60001 /* TerminalController+AgentJournal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A77A0008A1B2C3D4E5F60001 /* TerminalController+AgentJournal.swift */; }; + B9DED2921D296B8417DAABD0 /* TerminalController+AgentList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45034D44F7A14E4019983BD /* TerminalController+AgentList.swift */; }; D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */; }; 110434021104340211043402 /* TerminalController+AgentRestoreAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110434011104340111043401 /* TerminalController+AgentRestoreAdmission.swift */; }; 1C2E31B0392EDA1F21B150A4 /* TerminalController+AgentRestoreRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC27608B94D9C9D703147F2 /* TerminalController+AgentRestoreRecovery.swift */; }; @@ -4224,6 +4227,7 @@ D0B103010000000000000001 /* AcknowledgmentsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/About/AcknowledgmentsWindowController.swift; sourceTree = ""; }; A9E010000000000000000006 /* agent-session-react */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-react"; sourceTree = ""; }; A9E010000000000000000007 /* agent-session-solid */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "agent-session-solid"; sourceTree = ""; }; + 1547E2C6E5DE666CAE3B77F8 /* AgentActivityIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agents/AgentActivityIndex.swift; sourceTree = ""; }; 5FAC71D5AC71D5AC71D50002 /* AgentChatChildRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentChatChildRunTests.swift; sourceTree = ""; }; C7A52E000000000000000001 /* AgentChatEndedTranscriptListabilityCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatEndedTranscriptListabilityCache.swift"; sourceTree = ""; }; C7A53F000000000000000001 /* AgentChatFallbackTranscriptResolutionCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AgentChatFallbackTranscriptResolutionCoordinator.swift"; sourceTree = ""; }; @@ -4322,6 +4326,7 @@ F65760200000000000000002 /* AgentHibernationTranscriptHookStoreFileMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationTranscriptHookStoreFileMirror.swift"; sourceTree = ""; }; F65760210000000000000002 /* AgentHibernationTranscriptHookStoreRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AgentHibernationTranscriptHookStoreRecord.swift"; sourceTree = ""; }; F65760250000000000000002 /* AgentHibernationTranscriptSnapshotRaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHibernationTranscriptSnapshotRaceTests.swift; sourceTree = ""; }; + E2CAA8C3B254E024F4749E2E /* AgentHookActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Agents/AgentHookActivityTracker.swift; sourceTree = ""; }; A61E00010000000000000003 /* AgentHookDeliveryEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookDeliveryEvent.swift; sourceTree = ""; }; A61E00010000000000000006 /* AgentHookDeliveryProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookDeliveryProcess.swift; sourceTree = ""; }; A61E00010000000000000004 /* AgentHookDeliveryQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentHookDeliveryQueue.swift; sourceTree = ""; }; @@ -7395,6 +7400,7 @@ F87920000000000000000006 /* TerminalConfigurationReloadPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationReloadPhase.swift; sourceTree = ""; }; A10557050000000000000002 /* TerminalConfigurationSurfaceApplyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalConfigurationSurfaceApplyState.swift; sourceTree = ""; }; A77A0008A1B2C3D4E5F60001 /* TerminalController+AgentJournal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentJournal.swift"; sourceTree = ""; }; + F45034D44F7A14E4019983BD /* TerminalController+AgentList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Agents/TerminalController+AgentList.swift"; sourceTree = ""; }; D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentPromptDelivery.swift"; sourceTree = ""; }; 110434011104340111043401 /* TerminalController+AgentRestoreAdmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentRestoreAdmission.swift"; sourceTree = ""; }; 8BC27608B94D9C9D703147F2 /* TerminalController+AgentRestoreRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentRestoreRecovery.swift"; sourceTree = ""; }; @@ -10937,6 +10943,9 @@ 2DA313712597F71E6015ABBC /* TerminalSSHSessionDetectionTimeoutGate.swift */, 7611B3AEEB664825A7DD8733 /* TerminalSSHSessionDetector+Async.swift */, 61BF07E91B28C5740AD10DD9 /* TerminalSSHSessionDetector+ProcessInfo.swift */, + E2CAA8C3B254E024F4749E2E /* AgentHookActivityTracker.swift */, + 1547E2C6E5DE666CAE3B77F8 /* AgentActivityIndex.swift */, + F45034D44F7A14E4019983BD /* TerminalController+AgentList.swift */, ); path = Sources; sourceTree = ""; @@ -13123,6 +13132,7 @@ A5C017000000000000000007 /* AccountSignInView.swift in Sources */, D0B102020000000000000001 /* AcknowledgmentsView.swift in Sources */, D0B102010000000000000001 /* AcknowledgmentsWindowController.swift in Sources */, + CF6691E746957D8EAA5E1131 /* AgentActivityIndex.swift in Sources */, C7A52E000000000000000002 /* AgentChatEndedTranscriptListabilityCache.swift in Sources */, C7A53F000000000000000002 /* AgentChatFallbackTranscriptResolutionCoordinator.swift in Sources */, A6E57A0D0000000000000001 /* AgentChatProseStreamWakeDriver.swift in Sources */, @@ -13192,6 +13202,7 @@ F65760050000000000000001 /* AgentHibernationTranscriptGuard.swift in Sources */, F65760200000000000000001 /* AgentHibernationTranscriptHookStoreFileMirror.swift in Sources */, F65760210000000000000001 /* AgentHibernationTranscriptHookStoreRecord.swift in Sources */, + D0228AE2BD13B63BCBD94780 /* AgentHookActivityTracker.swift in Sources */, A61E00010000000000000001 /* AgentHookDeliveryEvent.swift in Sources */, A61E00010000000000000005 /* AgentHookDeliveryProcess.swift in Sources */, A61E00010000000000000002 /* AgentHookDeliveryQueue.swift in Sources */, @@ -14971,6 +14982,7 @@ F87920000000000000000005 /* TerminalConfigurationReloadPhase.swift in Sources */, A10557050000000000000001 /* TerminalConfigurationSurfaceApplyState.swift in Sources */, A77A0007A1B2C3D4E5F60001 /* TerminalController+AgentJournal.swift in Sources */, + B9DED2921D296B8417DAABD0 /* TerminalController+AgentList.swift in Sources */, D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */, 110434021104340211043402 /* TerminalController+AgentRestoreAdmission.swift in Sources */, 1C2E31B0392EDA1F21B150A4 /* TerminalController+AgentRestoreRecovery.swift in Sources */, diff --git a/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift b/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift index fad4ed83b614..6f471dcdff65 100644 --- a/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift +++ b/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift @@ -161,6 +161,7 @@ struct CLIClaudeHookTimeoutRegressionTests { ("UserPromptSubmit", "prompt-submit"), ("PreToolUse", "pre-tool-use"), ("PostToolUse", "push-notification"), + ("PostToolUse", "post-tool-use"), ] for (event, subcommand) in queuedHooks { try expectQueuedHook(hooks, event: event, subcommand: subcommand) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 1750876418ef..ed303152072b 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -119,7 +119,10 @@ def queued(subcommand: str, *, matcher: str = "") -> dict: direct(f"{direct_cli} hooks claude cron-create-guard", 5, matcher="CronCreate"), queued("pre-tool-use"), ], - "PostToolUse": [queued("push-notification", matcher="PushNotification")], + "PostToolUse": [ + queued("push-notification", matcher="PushNotification"), + queued("post-tool-use"), + ], "PermissionRequest": [direct(f"{direct_cli} hooks feed --source claude", 125)], } return json.dumps( From 49913c3a6ad92f3b74017838fc78fdfc611d0ea0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 06:25:45 -0400 Subject: [PATCH 5/9] agent.list: fail toward care when activity evidence is uncertain Review fixes for the activity index: - A compaction, resume or unknown SessionStart keeps the running turn; only startup and clear reset it. - Placement comes from the surface (cloud attachment, owning machine, remote terminal context), so a local pane in a remote workspace no longer reports survives_app_relaunch. - An unavailable or partial process census, or a local agent without a pid, sets foregroundCommandUnknown; the classifier never calls that safe (reason process_unknown). - Failed, denied and interrupted calls close: Claude also sends PostToolUseFailure to post-tool-use, an idle prompt ends the turn, and a later call of another tool closes a pending question. Both post-tool-use hooks are async. - Compaction keeps tool_use_id and agent_id; a post without an id closes the newest same-named call. - started_at is the PreToolUse time; since moves only on a change. - Subagent hooks and late posts never reopen a finished turn; hooks decide the turn only after they have seen a boundary. - Process filtering starts at the turn start, else at the last idle point, so older shells (such as shell-wrapped MCP servers) are ignored. - Relayed question PreToolUse is ignored: remote daemons send no PostToolUse, and the Feed overlay covers remote questions. - agent.list errors when the session owners are unavailable instead of returning an empty list, and redacts tool commands and argv. Refs #15266 Co-Authored-By: Claude Opus 5.5 (1M context) --- CLI/CMUXCLI+AgentHookAdmission.swift | 11 ++ CLI/CMUXCLI+AgentHookPayload.swift | 4 +- CLI/CMUXCLI+ClaudeHookSettings.swift | 19 +- .../CmuxAgentChat/Model/AgentActivity.swift | 13 +- .../Model/AgentActivitySignalAssembly.swift | 13 +- .../Model/AgentHookActivityState.swift | 136 +++++++++---- .../AgentActivityEvidenceTests.swift | 178 +++++++++++++++++- Resources/bin/cmux-claude-wrapper | 9 +- Sources/Agents/AgentActivityIndex.swift | 58 +++--- Sources/Agents/AgentHookActivityTracker.swift | 3 +- .../Agents/TerminalController+AgentList.swift | 20 +- .../CLIClaudeHookTimeoutRegressionTests.swift | 14 +- tests/test_claude_wrapper_hooks.py | 10 +- 13 files changed, 400 insertions(+), 88 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookAdmission.swift b/CLI/CMUXCLI+AgentHookAdmission.swift index 7a5ef4f66289..d08294071719 100644 --- a/CLI/CMUXCLI+AgentHookAdmission.swift +++ b/CLI/CMUXCLI+AgentHookAdmission.swift @@ -629,6 +629,17 @@ extension CMUXCLI { guard let rawObject = parsed.rawObject else { return fallback } let toolName = firstString(in: rawObject, keys: ["tool_name", "toolName"]) setBoundedString("tool_name", value: toolName, maximumLength: 80) + // Pairs a PostToolUse with its PreToolUse in the app's activity tracker. + setBoundedString( + "tool_use_id", + value: firstString(in: rawObject, keys: ["tool_use_id", "toolUseId"]), + maximumLength: 80 + ) + setBoundedString( + "agent_id", + value: firstString(in: rawObject, keys: ["agent_id", "agentId"]), + maximumLength: 80 + ) setBoundedString( "hook_event_name", value: firstString( diff --git a/CLI/CMUXCLI+AgentHookPayload.swift b/CLI/CMUXCLI+AgentHookPayload.swift index 58d1685b030b..875371c3f7cc 100644 --- a/CLI/CMUXCLI+AgentHookPayload.swift +++ b/CLI/CMUXCLI+AgentHookPayload.swift @@ -77,7 +77,7 @@ extension CMUXCLI { var compact: [String: Any] = [:] for key in [ - "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "transcript_path", "transcriptPath", "agent_id", "agentId", + "tool_name", "toolName", "tool_use_id", "toolUseId", "turn_id", "turnId", "conversation_id", "conversationId", "transcript_path", "transcriptPath", "agent_id", "agentId", "permission_mode", "permissionMode", "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "assistant_response", "assistantResponse", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "terminationReason", @@ -201,7 +201,7 @@ extension CMUXCLI { private func claudeHookCompactFieldLimit(for key: String) -> Int { switch key { - case "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "permission_mode", "permissionMode", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "campfire_event_type", "campfireEventType", "capability": + case "tool_name", "toolName", "tool_use_id", "toolUseId", "turn_id", "turnId", "conversation_id", "conversationId", "permission_mode", "permissionMode", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "campfire_event_type", "campfireEventType", "capability": return 80 case "transcript_path", "transcriptPath": return 240 diff --git a/CLI/CMUXCLI+ClaudeHookSettings.swift b/CLI/CMUXCLI+ClaudeHookSettings.swift index 89a9f697a10c..20387e124f9c 100644 --- a/CLI/CMUXCLI+ClaudeHookSettings.swift +++ b/CLI/CMUXCLI+ClaudeHookSettings.swift @@ -60,9 +60,18 @@ extension CMUXCLI { ), // Closes the call PreToolUse opened, so the app can tell a running // tool from a finished one. The app records it at admission and - // starts no hook process for it. + // starts no hook process for it. Async: nothing waits on it, and a + // late delivery only closes a call, never reopens a turn. Self.claudeQueuedHookGroup( - subcommand: "post-tool-use" + subcommand: "post-tool-use", + isAsync: true + ), + ] + // A failed, denied or interrupted call reports here instead of PostToolUse. + hooks["PostToolUseFailure"] = [ + Self.claudeQueuedHookGroup( + subcommand: "post-tool-use", + isAsync: true ), ] hooks["PermissionRequest"] = [ @@ -85,7 +94,8 @@ extension CMUXCLI { private static func claudeQueuedHookGroup( matcher: String = "", - subcommand: String + subcommand: String, + isAsync: Bool = false ) -> [String: Any] { // Wrapped sessions publish to their spool with shell builtins; the // CLI admission command is the fallback outside a live forwarder. @@ -100,7 +110,8 @@ extension CMUXCLI { disableEnvironmentVariable: producer.disableEnvironmentKey ) ), - timeout: agentHookDeclaredTimeoutSeconds + timeout: agentHookDeclaredTimeoutSeconds, + isAsync: isAsync ) } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift index bd5320b9d185..3f38d27fb2b1 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift @@ -5,7 +5,7 @@ import Foundation /// ``ChatAgentState`` is the coarse lifecycle the sidebar shows. This splits its /// `working` and `needsInput` cases into the activities a restart, update or /// hibernation decision needs to tell apart. Field names are the wire names used -/// by `cmux agents --json` and the updater. +/// by the agents view and the updater. public struct AgentActivity: Sendable, Equatable, Codable { public enum Kind: String, Sendable, Codable, CaseIterable { /// The turn is over and nothing is pending. @@ -96,6 +96,9 @@ public struct ResumeSafetyAssessment: Sendable, Equatable, Codable { case openQuestion = "open_question" case pendingPermission = "pending_permission" case draft + /// The process census was unavailable or incomplete, so a foreground + /// command cannot be ruled out. + case processUnknown = "process_unknown" case ended case unknown } @@ -128,6 +131,9 @@ public struct AgentActivitySignals: Sendable, Equatable { /// A live, non-background child in the agent's foreground process group, /// described by its argv. Holds even when hooks are stale. public var foregroundCommand: String? + /// The process census failed or was partial, so ``foregroundCommand`` being + /// nil proves nothing. + public var foregroundCommandUnknown = false /// Whether the agent's prompt holds a half-typed draft; nil when unknown. public var hasDraft: Bool? /// When the latest observed transition happened. @@ -190,6 +196,11 @@ public enum AgentActivityClassifier { return (AgentActivity(kind: .idle, since: since, source: .hook), .safe, .idle) }() var assessment = ResumeSafetyAssessment(safety: safety, reasons: [reason]) + // Without a census, a command may be running unseen: never call it safe. + if signals.foregroundCommandUnknown, assessment.safety == .safe, activity.kind != .ended { + assessment.safety = .care + assessment.reasons.append(.processUnknown) + } // A draft is unsaved human input: never safe to drop, whatever the agent does. if signals.hasDraft == true, activity.kind != .ended { assessment.safety = .risky diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift index 5a278ec0eb28..695e9705669d 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift @@ -13,6 +13,8 @@ public struct AgentActivityEvidence: Sendable { public var feedDecisionPending: Bool /// Description of a command the agent runs in the terminal's foreground. public var foregroundCommand: String? + /// The pane is local but its process census was unavailable or partial. + public var foregroundCommandUnknown: Bool public var hasDraft: Bool? public init( @@ -22,6 +24,7 @@ public struct AgentActivityEvidence: Sendable { hooks: AgentHookActivityState? = nil, feedDecisionPending: Bool = false, foregroundCommand: String? = nil, + foregroundCommandUnknown: Bool = false, hasDraft: Bool? = nil ) { self.registryState = registryState @@ -30,6 +33,7 @@ public struct AgentActivityEvidence: Sendable { self.hooks = hooks self.feedDecisionPending = feedDecisionPending self.foregroundCommand = foregroundCommand + self.foregroundCommandUnknown = foregroundCommandUnknown self.hasDraft = hasDraft } @@ -56,7 +60,13 @@ public struct AgentActivityEvidence: Sendable { // question the hooks already saw stays a question. signals.pendingPermission = feedDecisionPending && !signals.pendingQuestion signals.openTool = hooks?.openTool - signals.turnActive = hooks?.turnActive ?? registryWorking + // Hooks decide the turn only once they have seen a boundary; before that + // (say, the app started mid-turn) the registry's working state stands. + if let hooks, hooks.knowsTurnBoundary { + signals.turnActive = hooks.turnActive + } else { + signals.turnActive = hooks?.turnActive == true || registryWorking + } signals.lastToolFinished = hooks?.lastToolFinished == true signals.backgroundWork = hooks?.backgroundWork == true // Without a Feed decision or hook question, the registry's needs-input @@ -68,6 +78,7 @@ public struct AgentActivityEvidence: Sendable { if !(signals.backgroundWork && !signals.turnActive) { signals.foregroundCommand = foregroundCommand } + signals.foregroundCommandUnknown = foregroundCommandUnknown signals.hasDraft = hasDraft signals.since = hooks?.since ?? registrySince ?? registryLastActivityAt signals.hasHookEvidence = hooks != nil || registryHasHookLifecycleState diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift index a0e7bac7a272..e83db71825b2 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift @@ -4,15 +4,19 @@ import Foundation /// /// The app keeps one value per pane and session. It answers what the hooks alone /// know: whether a turn is running, which tool calls are open, and whether the -/// turn ended with background work or an open question. +/// turn ended with background work or an open question. When the hooks cannot +/// tell, the state keeps the busier reading. public struct AgentHookActivityState: Sendable, Equatable { /// A lifecycle hook reduced to what activity tracking needs. public enum Event: Sendable, Equatable { - case sessionStart + /// `fresh` is true for a new or cleared conversation (`startup`, `clear`). + /// A compaction or resume continues the running turn. + case sessionStart(fresh: Bool) case promptSubmit - /// `id` is the hook's `tool_use_id`, when present. - case preToolUse(id: String?, tool: AgentActivity.Tool) - case postToolUse(id: String?, toolName: String?) + /// `id` is the hook's `tool_use_id`; `subagent` marks a call made inside a subagent. + case preToolUse(id: String?, tool: AgentActivity.Tool, subagent: Bool) + /// Also sent for a failed, denied or interrupted call (PostToolUseFailure). + case postToolUse(id: String?, toolName: String?, subagent: Bool) case stop(backgroundWork: Bool) /// `idlePrompt` is true for the "waiting for your input" notification. case notification(idlePrompt: Bool) @@ -31,15 +35,20 @@ public struct AgentHookActivityState: Sendable, Equatable { } public private(set) var turnActive = false + /// Whether a turn boundary (prompt, Stop, idle prompt, fresh start) was seen. + /// Without one, `turnActive` says nothing and the registry decides. + public private(set) var knowsTurnBoundary = false public private(set) var lastToolFinished = false public private(set) var backgroundWork = false public private(set) var awaitingInput = false public private(set) var pendingQuestion = false public private(set) var ended = false - /// When the latest transition happened. + /// When the state last changed. public private(set) var since: Date? /// When the running turn's prompt was submitted; nil between turns. public private(set) var turnStartedAt: Date? + /// When the agent last went idle (Stop, idle prompt, fresh start); nil during a turn. + public private(set) var idleSince: Date? private var openTools: [OpenTool] = [] public init() {} @@ -51,64 +60,101 @@ public struct AgentHookActivityState: Sendable, Equatable { ?? openTools.last?.tool } + /// Commands started before this cannot belong to the current turn or idle period. + public var processesNotBefore: Date? { + turnActive ? turnStartedAt : idleSince + } + public mutating func apply(_ event: Event, at date: Date) { - since = date + let before = self + fold(event, at: date) + var unchanged = self + unchanged.since = before.since + since = unchanged == before ? before.since : date + } + + private mutating func fold(_ event: Event, at date: Date) { switch event { - case .sessionStart: + case .sessionStart(let fresh): + ended = false + guard fresh else { return } self = AgentHookActivityState() - since = date + knowsTurnBoundary = true + idleSince = date case .promptSubmit: turnActive = true + knowsTurnBoundary = true turnStartedAt = date + idleSince = nil lastToolFinished = false backgroundWork = false awaitingInput = false pendingQuestion = false ended = false openTools.removeAll() - case .preToolUse(let id, let tool): - turnActive = true - lastToolFinished = false - awaitingInput = false + case .preToolUse(let id, var tool, let subagent): if Self.questionTools.contains(tool.name) { pendingQuestion = true return } + pendingQuestion = false + if !subagent { lastToolFinished = false } + tool.startedAt = tool.startedAt ?? date if let id { openTools.removeAll { $0.id == id } } openTools.append(OpenTool(id: id, tool: tool)) if openTools.count > Self.maximumOpenTools { openTools.removeFirst(openTools.count - Self.maximumOpenTools) } - case .postToolUse(let id, let toolName): - turnActive = true - lastToolFinished = true + case .postToolUse(let id, let toolName, let subagent): if let toolName, Self.questionTools.contains(toolName) { pendingQuestion = false return } - if let id, let index = openTools.lastIndex(where: { $0.id == id }) { - openTools.remove(at: index) - } else if let toolName, let index = openTools.lastIndex(where: { $0.id == nil && $0.tool.name == toolName }) { - openTools.remove(at: index) - } + pendingQuestion = false + closeTool(id: id, name: toolName) + if turnActive, !subagent { lastToolFinished = true } case .stop(let background): - turnActive = false - turnStartedAt = nil - lastToolFinished = false + endTurn(at: date) pendingQuestion = false - awaitingInput = false backgroundWork = background - openTools.removeAll() case .notification(let idlePrompt): - if idlePrompt, !turnActive { - awaitingInput = true - } + // Claude asks for input only once the turn is over; an interrupted + // or failed call never reports its end otherwise. An open question + // stays open: that is what the prompt may be waiting on. + guard idlePrompt else { return } + endTurn(at: date) + awaitingInput = true case .sessionEnd: self = AgentHookActivityState() ended = true - since = date + knowsTurnBoundary = true } } + + private mutating func endTurn(at date: Date) { + turnActive = false + knowsTurnBoundary = true + turnStartedAt = nil + idleSince = date + lastToolFinished = false + awaitingInput = false + openTools.removeAll() + } + + /// Closes by `tool_use_id`. A post without one (compacted away) closes the + /// newest open call of the same tool; a post whose id matches nothing closes + /// the newest same-named call that never had an id. + private mutating func closeTool(id: String?, name: String?) { + if let id, let index = openTools.lastIndex(where: { $0.id == id }) { + openTools.remove(at: index) + return + } + guard let name else { return } + let index = openTools.lastIndex { open in + open.tool.name == name && (id == nil || open.id == nil) + } + if let index { openTools.remove(at: index) } + } } extension AgentHookActivityState.Event { @@ -122,18 +168,38 @@ extension AgentHookActivityState.Event { /// Parses one queued hook (`cmux hooks `) payload. /// + /// - Parameter relayBacked: The hook came from a remote host through the relay. + /// Remote daemons send no PostToolUse, so a remote question would never + /// close; the Feed overlay already reports remote questions and permissions. /// - Returns: The event and the hook's session id, or nil when the /// subcommand carries no activity fact or the payload is not a JSON object. - public static func parse(subcommand: String, payload: Data) -> (event: Self, sessionID: String?)? { + public static func parse( + subcommand: String, + payload: Data, + relayBacked: Bool = false + ) -> (event: Self, sessionID: String?)? { + guard let parsed = parseEvent(subcommand: subcommand, payload: payload) else { return nil } + if relayBacked, case .preToolUse(_, let tool, _) = parsed.event, + AgentHookActivityState.questionTools.contains(tool.name) { + return nil + } + return parsed + } + + private static func parseEvent(subcommand: String, payload: Data) -> (event: Self, sessionID: String?)? { guard subcommands.contains(subcommand), let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else { return nil } let sessionID = string(object, ["session_id", "sessionId"]) + let subagent = string(object, ["agent_id", "agentId"]) != nil let event: Self switch subcommand { case "session-start": - event = .sessionStart + // Only a new or cleared conversation starts clean. A compaction, + // resume or unknown source keeps whatever may still be running. + let source = string(object, ["source"])?.lowercased() + event = .sessionStart(fresh: source == "startup" || source == "clear") case "prompt-submit": event = .promptSubmit case "pre-tool-use": @@ -141,12 +207,14 @@ extension AgentHookActivityState.Event { let input = object["tool_input"] as? [String: Any] ?? object["toolInput"] as? [String: Any] ?? [:] event = .preToolUse( id: string(object, ["tool_use_id", "toolUseId"]), - tool: AgentActivity.Tool(name: name, command: commandSummary(toolName: name, input: input)) + tool: AgentActivity.Tool(name: name, command: commandSummary(toolName: name, input: input)), + subagent: subagent ) case "post-tool-use": event = .postToolUse( id: string(object, ["tool_use_id", "toolUseId"]), - toolName: string(object, ["tool_name", "toolName"]) + toolName: string(object, ["tool_name", "toolName"]), + subagent: subagent ) case "stop": event = .stop(backgroundWork: hasBackgroundWork(object)) diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift index a9da02fdcce6..6edee168418a 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift @@ -32,7 +32,8 @@ struct AgentActivityEvidenceTests { ("prompt-submit", #"{"session_id":"s"}"#), ("pre-tool-use", #"{"session_id":"s","tool_name":"Bash","tool_use_id":"t1","tool_input":{"command":"swift test\n--parallel"}}"#), ]) - #expect(hooks.openTool == AgentActivity.Tool(name: "Bash", command: "swift test --parallel")) + // started_at is the PreToolUse arrival time. + #expect(hooks.openTool == AgentActivity.Tool(name: "Bash", command: "swift test --parallel", startedAt: t0.addingTimeInterval(1))) let (activity, safety) = classify(.init(registryState: .working(since: t0), registryHasHookLifecycleState: true, registryLastActivityAt: t0, hooks: hooks)) #expect(activity.kind == .tool) @@ -153,11 +154,11 @@ struct AgentActivityEvidenceTests { func sessionBoundaries() { let hooks = fold([ ("pre-tool-use", #"{"tool_name":"Bash","tool_input":{"command":"x"}}"#), - ("session-start", #"{}"#), + ("session-start", #"{"source":"startup"}"#), ]) #expect(hooks == { var fresh = AgentHookActivityState() - fresh.apply(.sessionStart, at: t0.addingTimeInterval(1)) + fresh.apply(.sessionStart(fresh: true), at: t0.addingTimeInterval(1)) return fresh }()) let ended = fold([("prompt-submit", #"{}"#), ("session-end", #"{}"#)]) @@ -170,13 +171,180 @@ struct AgentActivityEvidenceTests { let parsed = AgentHookActivityState.Event.parse( subcommand: "post-tool-use", payload: Data(#"{"session_id":" abc ","tool_name":"Edit"}"#.utf8)) #expect(parsed?.sessionID == "abc") - #expect(parsed?.event == .postToolUse(id: nil, toolName: "Edit")) + #expect(parsed?.event == .postToolUse(id: nil, toolName: "Edit", subagent: false)) #expect(AgentHookActivityState.Event.parse(subcommand: "feed", payload: Data("{}".utf8)) == nil) #expect(AgentHookActivityState.Event.parse(subcommand: "stop", payload: Data("[]".utf8)) == nil) #expect(AgentHookActivityState.Event.parse(subcommand: "pre-tool-use", payload: Data("{}".utf8)) == nil) } } +@Suite("Agent hook activity review fixes") +struct AgentHookActivityReviewTests { + private let t0 = Date(timeIntervalSince1970: 1_000) + + private func fold(_ events: [(String, String)], relayBacked: Bool = false) -> AgentHookActivityState { + var state = AgentHookActivityState() + for (offset, (subcommand, json)) in events.enumerated() { + if let event = AgentHookActivityState.Event.parse( + subcommand: subcommand, payload: Data(json.utf8), relayBacked: relayBacked)?.event { + state.apply(event, at: t0.addingTimeInterval(TimeInterval(offset))) + } + } + return state + } + + private func classify(_ hooks: AgentHookActivityState?, unknown: Bool = false) -> (AgentActivity, ResumeSafetyAssessment) { + let evidence = AgentActivityEvidence(registryState: .idle, registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks, foregroundCommandUnknown: unknown) + let result = AgentActivityClassifier.classify(evidence.signals) + return (result.activity, result.safety) + } + + @Test("a compaction or resume SessionStart keeps the running turn; startup and clear reset") + func sessionStartSource() { + let running: [(String, String)] = [ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"make"}}"#), + ] + for source in ["compact", "resume", ""] { + let kept = fold(running + [("session-start", #"{"source":"\#(source)"}"#)]) + #expect(kept.turnActive, "\(source)") + #expect(kept.openTool?.name == "Bash", "\(source)") + } + for source in ["startup", "clear"] { + let reset = fold(running + [("session-start", #"{"source":"\#(source)"}"#)]) + #expect(!reset.turnActive, "\(source)") + #expect(reset.openTool == nil, "\(source)") + } + } + + @Test("an unavailable process census is never safe") + func processUnknown() { + let idle = fold([("prompt-submit", #"{}"#), ("stop", #"{}"#)]) + let (activity, safety) = classify(idle, unknown: true) + #expect(activity.kind == .idle) + #expect(safety == ResumeSafetyAssessment(safety: .care, reasons: [.idle, .processUnknown])) + #expect(classify(idle).1.safety == .safe) + let tool = fold([("prompt-submit", #"{}"#), ("pre-tool-use", #"{"tool_name":"Bash","tool_input":{"command":"x"}}"#)]) + #expect(classify(tool, unknown: true).1 == ResumeSafetyAssessment(safety: .risky, reasons: [.foregroundCommand])) + } + + @Test("an idle prompt ends the turn and closes calls that never reported back") + func idlePromptEndsTurn() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"make"}}"#), + ("notification", #"{"notification_type":"idle_prompt"}"#), + ]) + #expect(!hooks.turnActive) + #expect(hooks.openTool == nil) + #expect(classify(hooks).0.kind == .awaitingInput) + let question = fold([ + ("pre-tool-use", #"{"tool_name":"AskUserQuestion","tool_input":{}}"#), + ("notification", #"{"notification_type":"idle_prompt"}"#), + ]) + #expect(question.pendingQuestion) + } + + @Test("a later call of another tool closes a pending question") + func laterToolClosesQuestion() { + let pre = fold([ + ("pre-tool-use", #"{"tool_name":"ExitPlanMode","tool_input":{}}"#), + ("pre-tool-use", #"{"tool_name":"Edit","tool_input":{"file_path":"/x"}}"#), + ]) + #expect(!pre.pendingQuestion) + let post = fold([ + ("pre-tool-use", #"{"tool_name":"AskUserQuestion","tool_input":{}}"#), + ("post-tool-use", #"{"tool_name":"Read"}"#), + ]) + #expect(!post.pendingQuestion) + } + + @Test("a post without tool_use_id closes the newest same-named call") + func nilIDClose() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"a","tool_input":{"command":"one"}}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"two"}}"#), + ("post-tool-use", #"{"tool_name":"Bash"}"#), + ]) + #expect(hooks.openTool?.command == "one") + let unmatched = fold([ + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"a","tool_input":{"command":"one"}}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"zzz"}"#), + ]) + #expect(unmatched.openTool?.command == "one") + } + + @Test("started_at is the PreToolUse time") + func startedAt() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Bash","tool_input":{"command":"x"}}"#), + ]) + #expect(hooks.openTool?.startedAt == t0.addingTimeInterval(1)) + #expect(classify(hooks).0.tool?.startedAt == t0.addingTimeInterval(1)) + } + + @Test("relayed question PreToolUse is ignored; relayed tools still count") + func relayQuestion() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("pre-tool-use", #"{"tool_name":"AskUserQuestion","tool_input":{}}"#), + ], relayBacked: true) + #expect(!hooks.pendingQuestion) + let tool = fold([("pre-tool-use", #"{"tool_name":"Bash","tool_input":{"command":"x"}}"#)], relayBacked: true) + #expect(tool.openTool?.name == "Bash") + } + + @Test("subagent hooks and late posts never reopen a finished turn") + func subagentAfterStop() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("stop", #"{}"#), + ("pre-tool-use", #"{"tool_name":"Grep","tool_use_id":"g","agent_id":"sub","tool_input":{"pattern":"x"}}"#), + ("post-tool-use", #"{"tool_name":"Grep","tool_use_id":"g","agent_id":"sub"}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"late"}"#), + ]) + #expect(!hooks.turnActive) + #expect(!hooks.lastToolFinished) + #expect(hooks.openTool == nil) + #expect(classify(hooks).0.kind == .idle) + } + + @Test("since moves only when the state changes") + func sinceOnlyOnChange() { + let hooks = fold([ + ("prompt-submit", #"{}"#), + ("notification", #"{"notification_type":"permission_prompt"}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"none"}"#), + ]) + // The permission notification changes nothing; the post marks the turn between calls. + #expect(hooks.since == t0.addingTimeInterval(2)) + let quiet = fold([("prompt-submit", #"{}"#), ("notification", #"{"notification_type":"permission_prompt"}"#)]) + #expect(quiet.since == t0) + } + + @Test("without a seen turn boundary the registry's working state stands") + func registryBeforeBoundary() { + let hooks = fold([ + ("pre-tool-use", #"{"tool_name":"Bash","tool_use_id":"b","tool_input":{"command":"x"}}"#), + ("post-tool-use", #"{"tool_name":"Bash","tool_use_id":"b"}"#), + ]) + let evidence = AgentActivityEvidence(registryState: .working(since: t0), registryHasHookLifecycleState: true, + registryLastActivityAt: t0, hooks: hooks) + #expect(AgentActivityClassifier.classify(evidence.signals).activity.kind == .thinking) + } + + @Test("process filtering starts at the turn start, else at the last idle point") + func processesNotBefore() { + let idle = fold([("prompt-submit", #"{}"#), ("stop", #"{}"#)]) + #expect(idle.processesNotBefore == t0.addingTimeInterval(1)) + let running = fold([("prompt-submit", #"{}"#), ("stop", #"{}"#), ("prompt-submit", #"{}"#)]) + #expect(running.processesNotBefore == t0.addingTimeInterval(2)) + } +} + @Suite("Agent foreground command") struct AgentForegroundCommandTests { private typealias P = AgentForegroundCommand.Process @@ -240,7 +408,7 @@ struct AgentPanePlacementTests { var state = AgentHookActivityState() let start = Date(timeIntervalSince1970: 10) state.apply(.promptSubmit, at: start) - state.apply(.preToolUse(id: nil, tool: .init(name: "Bash")), at: start.addingTimeInterval(5)) + state.apply(.preToolUse(id: nil, tool: .init(name: "Bash"), subagent: false), at: start.addingTimeInterval(5)) #expect(state.turnStartedAt == start) state.apply(.stop(backgroundWork: false), at: start.addingTimeInterval(9)) #expect(state.turnStartedAt == nil) diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 7290285d2da5..4e7a3a55fa6e 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -1936,6 +1936,7 @@ CMUX_CLAUDE_QUEUED_HOOK_COMMAND direct_cli='\"${CMUX_CLAUDE_HOOK_CMUX_BIN:-cmux}\"' local q_prefix='{"hooks":[{"command":"' q_suffix='","timeout":5,"type":"command"}],"matcher":"' + local qa_prefix='{"hooks":[{"async":true,"command":"' local notification="${queued//@SUBCOMMAND@/notification}" local push_notification="${queued//@SUBCOMMAND@/push-notification}" local pre_tool_use="${queued//@SUBCOMMAND@/pre-tool-use}" @@ -1949,7 +1950,8 @@ CMUX_CLAUDE_QUEUED_HOOK_COMMAND cmux_claude_standard_hook_settings_value='{"hooks":{' cmux_claude_standard_hook_settings_value+='"Notification":['"$q_prefix$notification$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"PermissionRequest":[{"hooks":[{"command":"'"$direct_cli"' hooks feed --source claude","timeout":125,"type":"command"}],"matcher":""}],' - cmux_claude_standard_hook_settings_value+='"PostToolUse":['"$q_prefix$push_notification$q_suffix"'PushNotification"},'"$q_prefix$post_tool_use$q_suffix"'"}],' + cmux_claude_standard_hook_settings_value+='"PostToolUse":['"$q_prefix$push_notification$q_suffix"'PushNotification"},'"$qa_prefix$post_tool_use$q_suffix"'"}],' + cmux_claude_standard_hook_settings_value+='"PostToolUseFailure":['"$qa_prefix$post_tool_use$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"PreToolUse":[{"hooks":[{"command":"'"$direct_cli"' hooks claude cron-create-guard","timeout":5,"type":"command"}],"matcher":"CronCreate"},'"$q_prefix$pre_tool_use$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"SessionEnd":['"$q_prefix$session_end$q_suffix"'"}],' cmux_claude_standard_hook_settings_value+='"SessionStart":['"$q_prefix$session_start$q_suffix"'"}],' @@ -2050,8 +2052,9 @@ catch (_) { process.exit(1); } # bridge is the only way its message reaches the user inside cmux. ASYNC; # the handler mirrors the tool's own sent/skipped decision via # tool_response.localSent. -# - PostToolUse (every tool): queued; closes the call its PreToolUse opened -# so `agent.list` can tell a running tool from a finished one. +# - PostToolUse (every tool) and PostToolUseFailure: queued and ASYNC; close +# the call its PreToolUse opened so `agent.list` can tell a running tool +# from a finished, failed or interrupted one. # - PermissionRequest: cmux hooks feed. SYNC with a 125s timeout. # This is Claude Code's native blocking decision hook. Outside # bypassPermissions it covers permissions, ExitPlanMode, and AskUserQuestion diff --git a/Sources/Agents/AgentActivityIndex.swift b/Sources/Agents/AgentActivityIndex.swift index c874bf7492d3..588c052beb3f 100644 --- a/Sources/Agents/AgentActivityIndex.swift +++ b/Sources/Agents/AgentActivityIndex.swift @@ -1,5 +1,6 @@ import CmuxAgentChat import CmuxMobileHost +import CmuxSurfaceCatalogModel import Foundation /// One live agent pane with what it is doing and whether a restart can interrupt it. @@ -59,12 +60,20 @@ struct AgentActivityIndex { let panes = capture() let probes = panes.enumerated().compactMap { index, pane -> ForegroundProbe? in guard pane.placement == .local, let pid = pane.pid, pid > 0 else { return nil } - return ForegroundProbe(index: index, agentPID: pid, notBefore: pane.evidence.hooks?.turnStartedAt) + return ForegroundProbe(index: index, agentPID: pid, notBefore: pane.evidence.hooks?.processesNotBefore) } - let commands = probes.isEmpty ? [:] : await Self.foregroundCommands(probes, census: processCensus) + let census = probes.isEmpty + ? (commands: [:], complete: true) + : await Self.foregroundCommands(probes, census: processCensus) + let probed = Set(probes.map(\.index)) return panes.enumerated().map { index, pane in var evidence = pane.evidence - evidence.foregroundCommand = commands[index] + evidence.foregroundCommand = census.commands[index] + // A local agent whose process tree could not be read may be running + // anything: an incomplete census or a missing pid is not "no command". + if pane.placement == .local { + evidence.foregroundCommandUnknown = !probed.contains(index) || !census.complete + } let result = AgentActivityClassifier.classify(evidence.signals) return AgentActivitySnapshot( workspaceID: pane.workspaceID, panelID: pane.panelID, surfaceID: pane.panelID, @@ -114,11 +123,7 @@ struct AgentActivityIndex { let lifecycles = dock?.agentRuntimeByPanelId[panelID]?.agentLifecycleStates ?? workspace.agentLifecycleStatesByPanelId[panelID] ?? [:] let feedKey = FeedCoordinator.attentionStatusKey(forSource: record.agentKind.sourceName) - let placement = Self.placement( - workspace: workspace, - panel: panel, - dockRemote: dock?.terminalLinkIsRemoteTerminal(panelID) ?? false - ) + let placement = Self.placement(workspace: workspace, dock: dock, panelID: panelID, panel: panel) let hooks = hookActivity.state( surfaceID: panelID, sessionIDs: [record.sessionID] + [record.hookStoreSessionID].compactMap { $0 } @@ -141,18 +146,22 @@ struct AgentActivityIndex { } } - private static func placement(workspace: Workspace, panel: any Panel, dockRemote: Bool) -> AgentPanePlacement { - if (panel as? TerminalPanel)?.cloudAttachment != nil - || workspace.remoteConfiguration?.managedCloudVMID != nil { + /// Placement belongs to the surface, not its workspace: a local terminal or + /// Dock panel in a remote workspace still stops with this app. + private static func placement(workspace: Workspace, dock: DockSplitStore?, panelID: UUID, panel: any Panel) -> AgentPanePlacement { + let machine = dock.map { $0.machineOwningSurface(panelID) } ?? workspace.machineOwningSurface(panelID) + if (panel as? TerminalPanel)?.cloudAttachment != nil { return .cloud } - if let configuration = workspace.remoteConfiguration { - return .ssh(host: configuration.destination) + if case .cloud = machine { + return .cloud } - if workspace.isRemoteTmuxMirror || dockRemote { - return .ssh(host: nil) + let remote = dock.map { $0.terminalLinkIsRemoteTerminal(panelID) } ?? workspace.isRemoteTerminalContext(panelID) + guard remote else { return .local } + if case .ssh(let host) = machine { + return .ssh(host: host) } - return .local + return .ssh(host: dock == nil ? workspace.remoteConfiguration?.destination : nil) } private static func nonEmpty(_ value: String?) -> String? { @@ -160,14 +169,14 @@ struct AgentActivityIndex { return trimmed } - /// Reads one census off the main actor. An unavailable census leaves every - /// command unknown rather than proving none runs. + /// Reads one census off the main actor. `complete` is false when the census + /// was unavailable or partial: then a missing command proves nothing. private nonisolated static func foregroundCommands( _ probes: [ForegroundProbe], census: @Sendable () async -> CmuxTopProcessSnapshot - ) async -> [Int: String] { + ) async -> (commands: [Int: String], complete: Bool) { let snapshot = await census() - guard snapshot.captureIsAvailable else { return [:] } + guard snapshot.captureIsAvailable else { return ([:], false) } var commands: [Int: String] = [:] for probe in probes { var processes: [Int: AgentForegroundCommand.Process] = [:] @@ -185,12 +194,13 @@ struct AgentActivityIndex { } guard let pid = AgentForegroundCommand.commandPID( agentPID: probe.agentPID, processes: processes, notBefore: probe.notBefore - ), let process = snapshot.process(pid: pid), - let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments else { + ), let process = snapshot.process(pid: pid) else { continue } - commands[probe.index] = AgentForegroundCommand.describe(arguments: arguments) + // A command that runs but whose argv cannot be read still runs. + let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments ?? [] + commands[probe.index] = AgentForegroundCommand.describe(arguments: arguments) ?? process.name } - return commands + return (commands, snapshot.enumerationIsComplete) } } diff --git a/Sources/Agents/AgentHookActivityTracker.swift b/Sources/Agents/AgentHookActivityTracker.swift index ed5b68d25afb..78d7010fada3 100644 --- a/Sources/Agents/AgentHookActivityTracker.swift +++ b/Sources/Agents/AgentHookActivityTracker.swift @@ -28,7 +28,8 @@ final class AgentHookActivityTracker: @unchecked Sendable { let surfaceID = UUID(uuidString: rawSurfaceID), let parsed = AgentHookActivityState.Event.parse( subcommand: event.subcommand, - payload: Data(event.payload.utf8) + payload: Data(event.payload.utf8), + relayBacked: event.relayBacked ), let sessionID = parsed.sessionID ?? event.sessionID else { return diff --git a/Sources/Agents/TerminalController+AgentList.swift b/Sources/Agents/TerminalController+AgentList.swift index 5b2b74ecb507..621d1fd65809 100644 --- a/Sources/Agents/TerminalController+AgentList.swift +++ b/Sources/Agents/TerminalController+AgentList.swift @@ -1,4 +1,5 @@ import CmuxAgentChat +import CmuxSurfaceCatalogModel import Foundation extension TerminalController { @@ -12,16 +13,22 @@ extension TerminalController { return v2Error(id: id, code: "invalid_params", message: "agent.list takes no parameters") } return v2VmCall(id: id, timeoutSeconds: 10) { - let agents = await Self.captureAgentActivity() + guard let agents = await Self.captureAgentActivity() else { + throw SurfaceCatalogError.unsupported("Agent session owners are unavailable") + } return ["agents": agents.map(Self.agentListPayload)] } } @MainActor - private static func captureAgentActivity() async -> [AgentActivitySnapshot] { - await AgentActivityIndex( - agentRecords: { TerminalController.shared.agentChatTranscriptService?.sessionRecords(workspaceID: nil) }, - workspaceOwners: { AppDelegate.shared?.workspacesForRead(tabIds: $0) ?? [:] } + /// Nil when the app or its session registry is not up: an empty list would + /// wrongly say no agent is running. + private static func captureAgentActivity() async -> [AgentActivitySnapshot]? { + guard let appDelegate = AppDelegate.shared, + let sessions = TerminalController.shared.agentChatTranscriptService else { return nil } + return await AgentActivityIndex( + agentRecords: { sessions.sessionRecords(workspaceID: nil) }, + workspaceOwners: { [weak appDelegate] in appDelegate?.workspacesForRead(tabIds: $0) ?? [:] } ).snapshot() } @@ -36,7 +43,8 @@ extension TerminalController { if let tool = agent.activity.tool { activity["tool"] = [ "name": tool.name, - "command": orNull(tool.command), + // Commands and argv can carry credentials and home paths. + "command": orNull(tool.command.map(AgentHookNotificationPolicy.redactSensitiveCommand)), "started_at": timestamp(tool.startedAt), ] as [String: Any] } diff --git a/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift b/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift index 6f471dcdff65..57815007f523 100644 --- a/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift +++ b/cmuxCLITests/CLIClaudeHookTimeoutRegressionTests.swift @@ -161,11 +161,14 @@ struct CLIClaudeHookTimeoutRegressionTests { ("UserPromptSubmit", "prompt-submit"), ("PreToolUse", "pre-tool-use"), ("PostToolUse", "push-notification"), - ("PostToolUse", "post-tool-use"), ] for (event, subcommand) in queuedHooks { try expectQueuedHook(hooks, event: event, subcommand: subcommand) } + // Tool completion only closes an open call, so nothing waits on it. + for event in ["PostToolUse", "PostToolUseFailure"] { + try expectQueuedHook(hooks, event: event, subcommand: "post-tool-use", isAsync: true) + } try expectDirectHook( hooks, event: "PreToolUse", @@ -1467,7 +1470,8 @@ struct CLIClaudeHookTimeoutRegressionTests { private func expectQueuedHook( _ hooks: [String: Any], event: String, - subcommand: String + subcommand: String, + isAsync: Bool = false ) throws { let command = try hookCommand( hooks, @@ -1480,7 +1484,11 @@ struct CLIClaudeHookTimeoutRegressionTests { return entries.first { $0["command"] as? String == command } }.first) #expect(hook["timeout"] as? Int == 5) - #expect(hook["async"] == nil) + if isAsync { + #expect(hook["async"] as? Bool == true) + } else { + #expect(hook["async"] == nil) + } #expect(command.contains(#"--socket "$CMUX_SOCKET_PATH""#)) #expect(command.contains("CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.5")) #expect(command.contains("cat >/dev/null")) diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index ed303152072b..3b28aee3ab4e 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -93,7 +93,7 @@ def direct(command: str, timeout: int, *, matcher: str = "", asynchronous: bool hook["async"] = True return {"matcher": matcher, "hooks": [hook]} - def queued(subcommand: str, *, matcher: str = "") -> dict: + def queued(subcommand: str, *, matcher: str = "", asynchronous: bool = False) -> dict: return direct( spool_producer_command( "claude", @@ -102,6 +102,7 @@ def queued(subcommand: str, *, matcher: str = "") -> dict: ), 5, matcher=matcher, + asynchronous=asynchronous, ) hooks = { @@ -121,8 +122,9 @@ def queued(subcommand: str, *, matcher: str = "") -> dict: ], "PostToolUse": [ queued("push-notification", matcher="PushNotification"), - queued("post-tool-use"), + queued("post-tool-use", asynchronous=True), ], + "PostToolUseFailure": [queued("post-tool-use", asynchronous=True)], "PermissionRequest": [direct(f"{direct_cli} hooks feed --source claude", 125)], } return json.dumps( @@ -723,7 +725,7 @@ def test_live_socket_injects_supported_hooks_without_unlocking_bypass(failures: failures, ) hooks = settings.get("hooks", {}) - expected_hooks = {"SessionStart", "Stop", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest"} + expected_hooks = {"SessionStart", "Stop", "SubagentStop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest"} expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures) for hook_name, expected_subcommand in { "SessionStart": "session-start", @@ -1153,7 +1155,7 @@ def test_live_socket_merges_user_settings_into_hooks(failures: list[str]) -> Non ) expected_hooks = { "SessionStart", "Stop", "SubagentStop", "SessionEnd", - "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", + "Notification", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", } expect( set(settings.get("hooks", {}).keys()) == expected_hooks, From 592292d3554009608d78a00778baaf28b05c04da Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 06:54:16 -0400 Subject: [PATCH 6/9] agent.list: scope activity classifier and process census onto owning types package-conventions-lint flags caseless all-static enums. The classifier becomes AgentActivitySignals.classify() (plus AgentActivityEvidence.classify()), with the read-only and subagent-launcher tool sets on AgentActivity.Tool. AgentForegroundCommand becomes AgentProcessTree, an instantiated census with foregroundCommandPID(agentPID:notBefore:) and a static describe(arguments:). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CmuxAgentChat/Model/AgentActivity.swift | 19 +++++++++---- .../Model/AgentActivitySignalAssembly.swift | 5 ++++ .../Model/AgentHookActivityState.swift | 2 +- ...ndCommand.swift => AgentProcessTree.swift} | 19 +++++++++---- .../AgentActivityClassifierTests.swift | 2 +- .../AgentActivityEvidenceTests.swift | 28 +++++++++---------- Sources/Agents/AgentActivityIndex.swift | 12 ++++---- 7 files changed, 53 insertions(+), 34 deletions(-) rename Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/{AgentForegroundCommand.swift => AgentProcessTree.swift} (86%) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift index 3f38d27fb2b1..e2ec71f7e373 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivity.swift @@ -144,16 +144,23 @@ public struct AgentActivitySignals: Sendable, Equatable { public init() {} } -public enum AgentActivityClassifier { +extension AgentActivity.Tool { /// Tools that only read. A resume re-issues them without side effects. - public static let readOnlyTools: Set = [ + public static let readOnlyNames: Set = [ "Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead", "TodoRead", ] /// Subagent launchers: time inside them is subagent work, not a foreground tool. - public static let subagentTools: Set = ["Task", "Agent"] + public static let subagentLauncherNames: Set = ["Task", "Agent"] - public static func classify(_ signals: AgentActivitySignals) -> (activity: AgentActivity, safety: ResumeSafetyAssessment) { + public var isReadOnly: Bool { Self.readOnlyNames.contains(name) } + public var isSubagentLauncher: Bool { Self.subagentLauncherNames.contains(name) } +} + +extension AgentActivitySignals { + /// What the agent is doing and whether a restart can interrupt it. + public func classify() -> (activity: AgentActivity, safety: ResumeSafetyAssessment) { + let signals = self let since = signals.since let (activity, safety, reason): (AgentActivity, ResumeSafety, ResumeSafetyAssessment.Reason) = { if signals.ended { @@ -166,10 +173,10 @@ public enum AgentActivityClassifier { return (AgentActivity(kind: .question, since: since, source: .hook), .risky, .openQuestion) } if let tool = signals.openTool { - if subagentTools.contains(tool.name) { + if tool.isSubagentLauncher { return (AgentActivity(kind: .subagents, tool: tool, since: tool.startedAt ?? since, source: .hook), .care, .subagents) } - if readOnlyTools.contains(tool.name) { + if tool.isReadOnly { return (AgentActivity(kind: .tool, tool: tool, since: tool.startedAt ?? since, source: .hook), .care, .readOnlyTool) } return (AgentActivity(kind: .tool, tool: tool, since: tool.startedAt ?? since, source: .hook), .risky, .foregroundCommand) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift index 695e9705669d..6f8cabeef1de 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentActivitySignalAssembly.swift @@ -37,6 +37,11 @@ public struct AgentActivityEvidence: Sendable { self.hasDraft = hasDraft } + /// Shorthand for `signals.classify()`. + public func classify() -> (activity: AgentActivity, safety: ResumeSafetyAssessment) { + signals.classify() + } + public var signals: AgentActivitySignals { var signals = AgentActivitySignals() let registryEnded: Bool diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift index e83db71825b2..3ef7a1623713 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentHookActivityState.swift @@ -56,7 +56,7 @@ public struct AgentHookActivityState: Sendable, Equatable { /// The call that best describes what the agent is doing now: the newest /// open non-subagent tool, else the newest open subagent launcher. public var openTool: AgentActivity.Tool? { - openTools.last { !AgentActivityClassifier.subagentTools.contains($0.tool.name) }?.tool + openTools.last { !$0.tool.isSubagentLauncher }?.tool ?? openTools.last?.tool } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentProcessTree.swift similarity index 86% rename from Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift rename to Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentProcessTree.swift index f7c5bac68f72..9bf6da025779 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentForegroundCommand.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Model/AgentProcessTree.swift @@ -1,11 +1,12 @@ import Foundation -/// Finds the command an agent runs in its terminal's foreground from a process census. +/// An agent's process subtree from one census, used to find the command it runs +/// in its terminal's foreground. /// /// Agents run tool commands through a shell they spawn (`zsh -c ...`). Long-lived /// helpers such as MCP servers are also children of the agent but are not shells, /// so only a shell child in the terminal's foreground process group counts. -public enum AgentForegroundCommand { +public struct AgentProcessTree: Sendable { /// One process from a census. public struct Process: Sendable, Equatable { public var pid: Int @@ -27,14 +28,20 @@ public enum AgentForegroundCommand { public static let maximumLength = 120 static let maximumDepth = 32 + /// The census, keyed by pid. + public var processes: [Int: Process] + + public init(processes: [Int: Process]) { + self.processes = processes + } + /// The deepest foreground process under the agent's newest foreground shell child. /// /// - Parameters: /// - agentPID: The agent process. - /// - processes: The census, keyed by pid. /// - notBefore: Ignore shells started earlier (for example before the current turn). /// - Returns: The pid whose argv describes the command, or nil when none runs. - public static func commandPID(agentPID: Int, processes: [Int: Process], notBefore: Date? = nil) -> Int? { + public func foregroundCommandPID(agentPID: Int, notBefore: Date? = nil) -> Int? { guard agentPID > 0 else { return nil } var children: [Int: [Process]] = [:] for process in processes.values where process.pid != process.parentPID { @@ -47,12 +54,12 @@ public enum AgentForegroundCommand { } let shells = (children[agentPID] ?? []).filter { process in guard process.pid != agentPID, process.isTerminalForeground, - shellNames.contains(normalizedName(process.name)) else { return false } + Self.shellNames.contains(Self.normalizedName(process.name)) else { return false } if let notBefore, let startedAt = process.startedAt, startedAt < notBefore { return false } return true } guard var current = newest(shells) else { return nil } - for _ in 0.. Void) -> (AgentActivity, ResumeSafetyAssessment) { var signals = AgentActivitySignals() configure(&signals) - let result = AgentActivityClassifier.classify(signals) + let result = signals.classify() return (result.activity, result.safety) } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift index 6edee168418a..d7856493bc19 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/AgentActivityEvidenceTests.swift @@ -22,7 +22,7 @@ struct AgentActivityEvidenceTests { } private func classify(_ evidence: AgentActivityEvidence) -> (AgentActivity, ResumeSafetyAssessment) { - let result = AgentActivityClassifier.classify(evidence.signals) + let result = evidence.classify() return (result.activity, result.safety) } @@ -196,7 +196,7 @@ struct AgentHookActivityReviewTests { private func classify(_ hooks: AgentHookActivityState?, unknown: Bool = false) -> (AgentActivity, ResumeSafetyAssessment) { let evidence = AgentActivityEvidence(registryState: .idle, registryHasHookLifecycleState: true, registryLastActivityAt: t0, hooks: hooks, foregroundCommandUnknown: unknown) - let result = AgentActivityClassifier.classify(evidence.signals) + let result = evidence.classify() return (result.activity, result.safety) } @@ -333,7 +333,7 @@ struct AgentHookActivityReviewTests { ]) let evidence = AgentActivityEvidence(registryState: .working(since: t0), registryHasHookLifecycleState: true, registryLastActivityAt: t0, hooks: hooks) - #expect(AgentActivityClassifier.classify(evidence.signals).activity.kind == .thinking) + #expect(evidence.classify().activity.kind == .thinking) } @Test("process filtering starts at the turn start, else at the last idle point") @@ -346,8 +346,8 @@ struct AgentHookActivityReviewTests { } @Suite("Agent foreground command") -struct AgentForegroundCommandTests { - private typealias P = AgentForegroundCommand.Process +struct AgentProcessTreeTests { + private typealias P = AgentProcessTree.Process private func census(_ processes: [P]) -> [Int: P] { Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) }) @@ -359,14 +359,14 @@ struct AgentForegroundCommandTests { P(pid: 10, parentPID: 1, name: "claude", isTerminalForeground: true), P(pid: 11, parentPID: 10, name: "node", isTerminalForeground: true), ]) - #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes) == nil) + #expect(AgentProcessTree(processes: processes).foregroundCommandPID(agentPID: 10) == nil) let running = census([ P(pid: 10, parentPID: 1, name: "claude", isTerminalForeground: true), P(pid: 11, parentPID: 10, name: "node", isTerminalForeground: true), P(pid: 12, parentPID: 10, name: "zsh", isTerminalForeground: true), P(pid: 13, parentPID: 12, name: "swift-build", isTerminalForeground: true), ]) - #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: running) == 13) + #expect(AgentProcessTree(processes: running).foregroundCommandPID(agentPID: 10) == 13) } @Test("background shells and shells older than the turn are ignored") @@ -377,17 +377,17 @@ struct AgentForegroundCommandTests { P(pid: 12, parentPID: 10, name: "zsh", isTerminalForeground: false), P(pid: 14, parentPID: 10, name: "bash", isTerminalForeground: true, startedAt: Date(timeIntervalSince1970: 100)), ]) - #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes, notBefore: turn) == nil) - #expect(AgentForegroundCommand.commandPID(agentPID: 10, processes: processes) == 14) + #expect(AgentProcessTree(processes: processes).foregroundCommandPID(agentPID: 10, notBefore: turn) == nil) + #expect(AgentProcessTree(processes: processes).foregroundCommandPID(agentPID: 10) == 14) } @Test("describe shows a shell's script and truncates") func describe() { - #expect(AgentForegroundCommand.describe(arguments: ["/bin/zsh", "-c", "-l", "npm test"]) == "npm test") - #expect(AgentForegroundCommand.describe(arguments: ["/usr/bin/make", "-j8", "all"]) == "make -j8 all") - #expect(AgentForegroundCommand.describe(arguments: []) == nil) - let long = AgentForegroundCommand.describe(arguments: ["x", String(repeating: "a", count: 300)]) - #expect(long?.count == AgentForegroundCommand.maximumLength) + #expect(AgentProcessTree.describe(arguments: ["/bin/zsh", "-c", "-l", "npm test"]) == "npm test") + #expect(AgentProcessTree.describe(arguments: ["/usr/bin/make", "-j8", "all"]) == "make -j8 all") + #expect(AgentProcessTree.describe(arguments: []) == nil) + let long = AgentProcessTree.describe(arguments: ["x", String(repeating: "a", count: 300)]) + #expect(long?.count == AgentProcessTree.maximumLength) } } diff --git a/Sources/Agents/AgentActivityIndex.swift b/Sources/Agents/AgentActivityIndex.swift index 588c052beb3f..3c915b84c9f9 100644 --- a/Sources/Agents/AgentActivityIndex.swift +++ b/Sources/Agents/AgentActivityIndex.swift @@ -74,7 +74,7 @@ struct AgentActivityIndex { if pane.placement == .local { evidence.foregroundCommandUnknown = !probed.contains(index) || !census.complete } - let result = AgentActivityClassifier.classify(evidence.signals) + let result = evidence.classify() return AgentActivitySnapshot( workspaceID: pane.workspaceID, panelID: pane.panelID, surfaceID: pane.panelID, paneID: pane.paneID, name: pane.name, agentKind: pane.agentKind, sessionID: pane.sessionID, @@ -179,10 +179,10 @@ struct AgentActivityIndex { guard snapshot.captureIsAvailable else { return ([:], false) } var commands: [Int: String] = [:] for probe in probes { - var processes: [Int: AgentForegroundCommand.Process] = [:] + var processes: [Int: AgentProcessTree.Process] = [:] for pid in snapshot.descendantPIDs(rootPID: probe.agentPID, includeRoot: true) { guard let process = snapshot.process(pid: pid) else { continue } - processes[pid] = AgentForegroundCommand.Process( + processes[pid] = AgentProcessTree.Process( pid: pid, parentPID: process.parentPID, name: process.name, @@ -192,14 +192,14 @@ struct AgentActivityIndex { } ) } - guard let pid = AgentForegroundCommand.commandPID( - agentPID: probe.agentPID, processes: processes, notBefore: probe.notBefore + guard let pid = AgentProcessTree(processes: processes).foregroundCommandPID( + agentPID: probe.agentPID, notBefore: probe.notBefore ), let process = snapshot.process(pid: pid) else { continue } // A command that runs but whose argv cannot be read still runs. let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments ?? [] - commands[probe.index] = AgentForegroundCommand.describe(arguments: arguments) ?? process.name + commands[probe.index] = AgentProcessTree.describe(arguments: arguments) ?? process.name } return (commands, snapshot.enumerationIsComplete) } From 56d4d91e87c0bae0866258445b2e8602e644ed70 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 07:40:31 -0400 Subject: [PATCH 7/9] agent.list: report tool commands as recorded The CLI's redaction policy is not compiled into the app target. The socket is same-user and never relayed, and the updater shows these commands to the user, so report them as recorded. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Agents/TerminalController+AgentList.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Agents/TerminalController+AgentList.swift b/Sources/Agents/TerminalController+AgentList.swift index 621d1fd65809..e36f514fdd08 100644 --- a/Sources/Agents/TerminalController+AgentList.swift +++ b/Sources/Agents/TerminalController+AgentList.swift @@ -43,8 +43,9 @@ extension TerminalController { if let tool = agent.activity.tool { activity["tool"] = [ "name": tool.name, - // Commands and argv can carry credentials and home paths. - "command": orNull(tool.command.map(AgentHookNotificationPolicy.redactSensitiveCommand)), + // Unredacted: agent.list is a same-user local socket kept off the + // remote relay, and the same user can read this argv with ps. + "command": orNull(tool.command), "started_at": timestamp(tool.startedAt), ] as [String: Any] } From ed0f5f047ffb8974535b44027e784fc989888e4a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 12:43:29 -0400 Subject: [PATCH 8/9] Don't count processes that exit mid-census as unknown agent activity The agent census failed closed whenever any process on the machine exited between the PID list and its record read. On a busy host that is nearly every sample, so idle agents read care (process_unknown) instead of safe. A process that already exited cannot be a live foreground command; only an unavailable census or a truncated PID list leaves one unaccounted for. DarwinProcessListing and CmuxTopProcessSnapshot now carry pidListIsComplete alongside the strict enumerationIsComplete, which hibernation and memory pressure keep using. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Process/DarwinProcessEnumerator.swift | 5 +++-- .../CmuxFoundation/Process/DarwinProcessListing.swift | 10 ++++++++++ .../DarwinResourceSamplingTests.swift | 3 +++ Sources/Agents/AgentActivityIndex.swift | 7 +++++-- Sources/CmuxTopProcessSampler+Enrichment.swift | 3 ++- Sources/CmuxTopProcessSampler.swift | 3 ++- Sources/CmuxTopSnapshot.swift | 8 +++++++- 7 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessEnumerator.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessEnumerator.swift index 363f388b5025..9d937d57178c 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessEnumerator.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessEnumerator.swift @@ -24,7 +24,7 @@ public struct DarwinProcessEnumerator { public func capture() -> DarwinProcessListing { let initialCount = Int(listPIDs(nil, 0)) guard initialCount > 0 else { - return DarwinProcessListing(processes: [], isComplete: false, missingProcessCount: 0) + return DarwinProcessListing(processes: [], isComplete: false, missingProcessCount: 0, pidListIsComplete: false) } // A bounded retry absorbs normal fork/exit churn. Exhausting it is an // incomplete sample, never evidence that the unseen subtree is empty. @@ -63,7 +63,8 @@ public struct DarwinProcessEnumerator { return DarwinProcessListing( processes: processes, isComplete: listingComplete && missingCount == 0, - missingProcessCount: missingCount + missingProcessCount: missingCount, + pidListIsComplete: listingComplete ) } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift index c0a7f4912210..610e2d985404 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift @@ -13,4 +13,14 @@ public struct DarwinProcessListing: Sendable { public let isComplete: Bool /// Listed PIDs whose topology could not be read; excludes unknown truncated rows. public let missingProcessCount: Int + /// Whether the PID list itself was whole. Unlike ``isComplete`` it ignores + /// listed PIDs that exited before their record was read: those no longer run. + public let pidListIsComplete: Bool + + public init(processes: [proc_bsdinfo], isComplete: Bool, missingProcessCount: Int, pidListIsComplete: Bool? = nil) { + self.processes = processes + self.isComplete = isComplete + self.missingProcessCount = missingProcessCount + self.pidListIsComplete = pidListIsComplete ?? isComplete + } } diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift index 7b5bb10ad86a..dabc1d6fc368 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift @@ -23,6 +23,8 @@ struct DarwinResourceSamplingTests { ).capture() #expect(!listing.isComplete) #expect(listing.missingProcessCount == 1) + // The PID list was whole: 43 exited before it was read, it was not unseen. + #expect(listing.pidListIsComplete) } @Test("Truncated PID buffers remain incomplete after bounded retries") @@ -45,6 +47,7 @@ struct DarwinResourceSamplingTests { ).capture() #expect(readCount == 3) #expect(!listing.isComplete) + #expect(!listing.pidListIsComplete) #expect(!listing.processes.isEmpty) } diff --git a/Sources/Agents/AgentActivityIndex.swift b/Sources/Agents/AgentActivityIndex.swift index 3c915b84c9f9..bbc84168c857 100644 --- a/Sources/Agents/AgentActivityIndex.swift +++ b/Sources/Agents/AgentActivityIndex.swift @@ -170,7 +170,7 @@ struct AgentActivityIndex { } /// Reads one census off the main actor. `complete` is false when the census - /// was unavailable or partial: then a missing command proves nothing. + /// was unavailable or its PID list truncated: then a missing command proves nothing. private nonisolated static func foregroundCommands( _ probes: [ForegroundProbe], census: @Sendable () async -> CmuxTopProcessSnapshot @@ -201,6 +201,9 @@ struct AgentActivityIndex { let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments ?? [] commands[probe.index] = AgentProcessTree.describe(arguments: arguments) ?? process.name } - return (commands, snapshot.enumerationIsComplete) + // A listed process that exited before it was read no longer runs, so + // only a truncated PID list leaves a command unaccounted for. Build + // hosts churn through short-lived processes on every sample. + return (commands, snapshot.pidListIsComplete) } } diff --git a/Sources/CmuxTopProcessSampler+Enrichment.swift b/Sources/CmuxTopProcessSampler+Enrichment.swift index 0d9ff7a600ae..0c8953cbdbc4 100644 --- a/Sources/CmuxTopProcessSampler+Enrichment.swift +++ b/Sources/CmuxTopProcessSampler+Enrichment.swift @@ -53,7 +53,8 @@ extension CmuxTopProcessSampler { includesProcessDetails: combined.contains(.details), includesCMUXScope: combined.contains(.scope), includesResources: combined.contains(.resources), enumerationIsComplete: capture.snapshot.enumerationIsComplete && missingCount == 0, - enumerationMissingProcessCount: missingCount + enumerationMissingProcessCount: missingCount, + pidListIsComplete: capture.snapshot.pidListIsComplete ) return CmuxTopProcessCapture(listing: capture.listing, snapshot: snapshot, fields: combined) } diff --git a/Sources/CmuxTopProcessSampler.swift b/Sources/CmuxTopProcessSampler.swift index b6bd6b38e931..59e03be8ce94 100644 --- a/Sources/CmuxTopProcessSampler.swift +++ b/Sources/CmuxTopProcessSampler.swift @@ -18,7 +18,8 @@ struct CmuxTopProcessSampler: Sendable { processes: records, sampledAt: startedAt, includesProcessDetails: false, includesCMUXScope: false, includesResources: false, enumerationIsComplete: listing.isComplete && missing == 0, - enumerationMissingProcessCount: missing + enumerationMissingProcessCount: missing, + pidListIsComplete: listing.pidListIsComplete ) return CmuxTopProcessCapture(listing: listing, snapshot: snapshot, fields: []) } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 726cc5c042a4..9e35724a9e0a 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -75,6 +75,9 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { let captureIsAvailable: Bool let enumerationIsComplete: Bool let enumerationMissingProcessCount: Int + /// Whether the PID list was whole, ignoring processes that exited before + /// they were read. Enough to rule out a live command; not a complete census. + let pidListIsComplete: Bool private let includesProcessDetails: Bool let includesResources: Bool private let includesCMUXScope: Bool @@ -93,10 +96,13 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { includesResources: Bool = true, enumerationIsComplete: Bool = true, enumerationMissingProcessCount: Int = 0, + pidListIsComplete: Bool? = nil, captureIsAvailable: Bool = true ) { self.captureIsAvailable = captureIsAvailable - self.enumerationIsComplete = enumerationIsComplete && enumerationMissingProcessCount == 0 + let complete = enumerationIsComplete && enumerationMissingProcessCount == 0 + self.enumerationIsComplete = complete + self.pidListIsComplete = captureIsAvailable && (pidListIsComplete ?? complete) self.enumerationMissingProcessCount = max(0, enumerationMissingProcessCount) self.sampledAt = sampledAt self.includesProcessDetails = includesProcessDetails From ce3ce297f0fbdd1aaf269f473781ea3a258fef84 Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 13:04:49 -0400 Subject: [PATCH 9/9] Cover pidListIsComplete in the synthetic reader and census tests The synthetic reader rebuilt its listing without the new flag, so a missing PID read as a truncated list there, the opposite of production. Tests now cover an exited PID (whole list), a truncated or unavailable census (not whole) and an empty PID list. The census JSON reports pid_list_complete. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Process/DarwinProcessListing.swift | 3 ++- .../DarwinResourceSamplingTests.swift | 10 ++++++++++ Sources/Agents/AgentActivityIndex.swift | 7 ++++--- Sources/CmuxTopSnapshot.swift | 3 ++- ...ProcessSnapshotCaptureCoordinatorTests.swift | 17 +++++++++++++++++ cmuxTests/SyntheticProcessSnapshotReader.swift | 3 ++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift index 610e2d985404..f2aca099ebf4 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Process/DarwinProcessListing.swift @@ -14,7 +14,8 @@ public struct DarwinProcessListing: Sendable { /// Listed PIDs whose topology could not be read; excludes unknown truncated rows. public let missingProcessCount: Int /// Whether the PID list itself was whole. Unlike ``isComplete`` it ignores - /// listed PIDs that exited before their record was read: those no longer run. + /// listed PIDs whose record could not be read: they exited or were still being + /// created, the same as a process forked just after the list was taken. public let pidListIsComplete: Bool public init(processes: [proc_bsdinfo], isComplete: Bool, missingProcessCount: Int, pidListIsComplete: Bool? = nil) { diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift index dabc1d6fc368..ac581a472387 100644 --- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift +++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/DarwinResourceSamplingTests.swift @@ -27,6 +27,16 @@ struct DarwinResourceSamplingTests { #expect(listing.pidListIsComplete) } + @Test("An empty PID list is never a whole one") + func emptyListing() { + let listing = DarwinProcessEnumerator( + listPIDs: { _, _ in 0 }, + readProcess: { _ in nil } + ).capture() + #expect(!listing.isComplete) + #expect(!listing.pidListIsComplete) + } + @Test("Truncated PID buffers remain incomplete after bounded retries") func growingProcessTableFailsClosed() { var readCount = 0 diff --git a/Sources/Agents/AgentActivityIndex.swift b/Sources/Agents/AgentActivityIndex.swift index bbc84168c857..f298615f2978 100644 --- a/Sources/Agents/AgentActivityIndex.swift +++ b/Sources/Agents/AgentActivityIndex.swift @@ -201,9 +201,10 @@ struct AgentActivityIndex { let arguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process)?.arguments ?? [] commands[probe.index] = AgentProcessTree.describe(arguments: arguments) ?? process.name } - // A listed process that exited before it was read no longer runs, so - // only a truncated PID list leaves a command unaccounted for. Build - // hosts churn through short-lived processes on every sample. + // A listed process that could not be read exited or was still being + // created (like a fork just after the list), so only a truncated PID + // list leaves a command unaccounted for. Build hosts churn through + // short-lived processes on every sample. return (commands, snapshot.pidListIsComplete) } } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 9e35724a9e0a..d7e4a3954d59 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -157,7 +157,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { "resource_details": includesResources, "cmux_scope": includesCMUXScope, "enumeration_complete": enumerationIsComplete, - "enumeration_missing_process_count": enumerationMissingProcessCount + "enumeration_missing_process_count": enumerationMissingProcessCount, + "pid_list_complete": pidListIsComplete ] } diff --git a/cmuxTests/CmuxTopProcessSnapshotCaptureCoordinatorTests.swift b/cmuxTests/CmuxTopProcessSnapshotCaptureCoordinatorTests.swift index 660bf7a16e09..52dfd19379c2 100644 --- a/cmuxTests/CmuxTopProcessSnapshotCaptureCoordinatorTests.swift +++ b/cmuxTests/CmuxTopProcessSnapshotCaptureCoordinatorTests.swift @@ -90,10 +90,26 @@ struct CmuxTopProcessSnapshotCaptureCoordinatorTests { try sampler.enrich(sampler.capture(), fields: [.details, .scope, .resources]) }.value #expect(!value.snapshot.enumerationIsComplete) + #expect(!value.snapshot.pidListIsComplete) #expect(value.snapshot.enumerationMissingProcessCount == 1) #expect(value.snapshot.process(pid: 123) == nil) } + @Test func aProcessThatExitsMidCensusLeavesThePIDListWhole() async throws { + let reader = SyntheticProcessSnapshotReader(count: 1000) + reader.state.withLock { $0.missingPID = 123 } + let sampler = CmuxTopProcessSampler(reader: reader) + let value = try await Task.detached { + try sampler.enrich(sampler.capture(), fields: [.details, .scope, .resources]) + }.value + #expect(!value.snapshot.enumerationIsComplete) + #expect(value.snapshot.pidListIsComplete) + let unavailable = CmuxTopProcessSnapshot( + processes: [], sampledAt: Date(), includesProcessDetails: false, captureIsAvailable: false + ) + #expect(!unavailable.pidListIsComplete) + } + @Test func enrichmentRejectsReusedPIDAndNeverRecensuses() async throws { let reader = SyntheticProcessSnapshotReader(count: 1000) let sampler = CmuxTopProcessSampler(reader: reader) @@ -102,6 +118,7 @@ struct CmuxTopProcessSnapshotCaptureCoordinatorTests { let rich = try await Task.detached { try sampler.enrich(base, fields: [.details, .scope, .resources]) }.value #expect(rich.snapshot.process(pid: 123) == nil) #expect(!rich.snapshot.enumerationIsComplete) + #expect(rich.snapshot.pidListIsComplete) #expect(rich.snapshot.enumerationMissingProcessCount == 1) #expect(reader.state.withLock { $0.counts.enumerations } == 1) #expect(reader.state.withLock { $0.counts.paths } == 999) diff --git a/cmuxTests/SyntheticProcessSnapshotReader.swift b/cmuxTests/SyntheticProcessSnapshotReader.swift index d179cf09bb50..c2420e3e627c 100644 --- a/cmuxTests/SyntheticProcessSnapshotReader.swift +++ b/cmuxTests/SyntheticProcessSnapshotReader.swift @@ -84,7 +84,8 @@ final class SyntheticProcessSnapshotReader: CmuxTopProcessReading, Sendable { return DarwinProcessListing( processes: listing.processes, isComplete: listing.isComplete && state.withLock { $0.complete }, - missingProcessCount: listing.missingProcessCount + missingProcessCount: listing.missingProcessCount, + pidListIsComplete: listing.pidListIsComplete && state.withLock { $0.complete } ) }