Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2d50f34
Install updates automatically at a quiet moment; explicit installs re…
teamleaderleo Sep 28, 2026
fffb625
Classify agents by resume safety for update relaunches
teamleaderleo Sep 28, 2026
b2cdc7a
Add an agent activity model and resume-safety classifier
teamleaderleo Sep 28, 2026
435b44f
Localize update relaunch safety strings
teamleaderleo Sep 28, 2026
e9ef0a0
Fold agent hooks and process facts into activity signals
teamleaderleo Sep 28, 2026
dbd01c2
Nudge agents cut off mid-task to continue after an update relaunch
teamleaderleo Sep 28, 2026
be37f16
Report remote agents in current work by adapter and session state
teamleaderleo Sep 28, 2026
335ff68
Add agent.list: live agent panes with activity and resume safety
teamleaderleo Sep 28, 2026
acaba7a
Test that a menu install asks first while risky agents hold an automa…
teamleaderleo Sep 28, 2026
df1db3f
Ask before a menu install stops risky work; expire unused continuatio…
teamleaderleo Sep 28, 2026
49913c3
agent.list: fail toward care when activity evidence is uncertain
teamleaderleo Sep 28, 2026
872da64
Read mutating index captures outside the test macros
teamleaderleo Sep 28, 2026
592292d
agent.list: scope activity classifier and process census onto owning …
teamleaderleo Sep 28, 2026
56d4d91
agent.list: report tool commands as recorded
teamleaderleo Sep 28, 2026
b301b63
Dogfood updates from CMUX_UPDATE_DOGFOOD_FEED_URL
teamleaderleo Sep 28, 2026
a09c2b8
Merge remote-tracking branch 'upstream/main' into feat/agent-activity
teamleaderleo Sep 28, 2026
69b130d
Merge remote-tracking branch 'upstream/main' into feat/hands-free-aut…
teamleaderleo Sep 28, 2026
790209e
Test that the launch check downloads when installs are automatic
teamleaderleo Sep 28, 2026
1ea7769
Download at launch when updates install automatically
teamleaderleo Sep 28, 2026
15e08cd
Merge upstream/main into feat/agent-activity
teamleaderleo Sep 28, 2026
2e1b29a
Merge upstream/main into feat/agent-activity
teamleaderleo Sep 28, 2026
bb519ef
Merge commit 'b681e7e943ba0705b99306b2f9d58e89b9dc1703' into feat/han…
teamleaderleo Sep 28, 2026
ee70bfc
Merge upstream/main into feat/agent-activity
teamleaderleo Sep 28, 2026
5fab879
Merge remote-tracking branch 'mf/feat/agent-activity' into feat/updat…
teamleaderleo Sep 28, 2026
3bedf86
Hold update relaunches on the shared agent classifier
teamleaderleo Sep 28, 2026
0596e2e
Merge remote-tracking branch 'upstream/main' into feat/update-gate-ag…
teamleaderleo Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLI/CMUXCLI+AgentHookAdmission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,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(
Expand Down
4 changes: 2 additions & 2 deletions CLI/CMUXCLI+AgentHookPayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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",
Expand Down Expand Up @@ -202,7 +202,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
Expand Down
21 changes: 19 additions & 2 deletions CLI/CMUXCLI+ClaudeHookSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ 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. Async: nothing waits on it, and a
// late delivery only closes a call, never reopens a turn.
Self.claudeQueuedHookGroup(
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"] = [
Self.claudeHookGroup(
Expand All @@ -82,7 +97,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.
Expand All @@ -97,7 +113,8 @@ extension CMUXCLI {
disableEnvironmentVariable: producer.disableEnvironmentKey
)
),
timeout: agentHookDeclaredTimeoutSeconds
timeout: agentHookDeclaredTimeoutSeconds,
isAsync: isAsync
)
}

Expand Down
6 changes: 4 additions & 2 deletions CLI/CMUXCLI+Restore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ extension CMUXCLI {
preparedArgumentsWorkingDirectory: normalizedRestoreWorkingDirectory(
record.preparedArgumentsWorkingDirectory
),
observedPermissionMode: record.permissionMode
observedPermissionMode: record.permissionMode,
continuationPrompt: record.continuationPrompt
)
invocation = AgentRestorePlanner(
executableFileResolver: AgentRestoreExecutableFileResolver(),
Expand Down Expand Up @@ -408,7 +409,8 @@ extension CMUXCLI {
?? (object["prepared_fork_arguments_working_directory"] as? String),
permissionMode: object["permission_mode"] as? String,
legacyCommand: legacyCommand,
legacyForkCommand: legacyForkCommand
legacyForkCommand: legacyForkCommand,
continuationPrompt: object["continuation_prompt"] as? String
)
}

Expand Down
5 changes: 4 additions & 1 deletion CLI/CMUXCLI+RestoreRecord.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ extension CMUXCLI {
let permissionMode: String?
let legacyCommand: String?
let legacyForkCommand: String?
/// A prompt for the resumed agent's next turn; see `AgentRestoreRequest.continuationPrompt`.
let continuationPrompt: String?

/// Compatibility alias matching the wire field name `fork_command`.
var forkCommand: String? { legacyForkCommand }
Expand Down Expand Up @@ -44,7 +46,8 @@ extension CMUXCLI {
legacyCommand: legacyCommand,
// A compatibility fork command also embeds the superseded
// checkpoint; never replay it after Hermes identity recovery.
legacyForkCommand: nil
legacyForkCommand: nil,
continuationPrompt: continuationPrompt
)
}
}
Expand Down
9 changes: 8 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29087,6 +29087,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")
Expand Down Expand Up @@ -29472,7 +29479,7 @@ struct CMUXCLI {
telemetry.breadcrumb("claude-hook.help")
print(
"""
cmux claude-hook <session-start|stop|session-end|notification|push-notification|prompt-submit|pre-tool-use> [--workspace <id|index>] [--surface <id|index>]
cmux claude-hook <session-start|stop|session-end|notification|push-notification|prompt-submit|pre-tool-use|post-tool-use> [--workspace <id|index>] [--surface <id|index>]
"""
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
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 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.
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
/// The process census was unavailable or incomplete, so a foreground
/// command cannot be ruled out.
case processUnknown = "process_unknown"
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?
/// 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.
public var since: Date?
/// Whether any lifecycle hook has ever reported for this pane.
public var hasHookEvidence = true

public init() {}
}

extension AgentActivity.Tool {
/// Tools that only read. A resume re-issues them without side effects.
public static let readOnlyNames: Set<String> = [
"Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead", "TodoRead",
]

/// Subagent launchers: time inside them is subagent work, not a foreground tool.
public static let subagentLauncherNames: Set<String> = ["Task", "Agent"]

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 {
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 tool.isSubagentLauncher {
return (AgentActivity(kind: .subagents, tool: tool, since: tool.startedAt ?? since, source: .hook), .care, .subagents)
}
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)
}
// 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])
// 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
assessment.reasons.append(.draft)
}
return (activity, assessment)
}
}
Loading
Loading