Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
58e6848
refactor(agent-pane): read an event's posting process through an inje…
lawrencecchen Oct 30, 2026
b89a7aa
test(agent-pane): the person-input checks make no system call per eve…
lawrencecchen Oct 30, 2026
6a36a3f
fix(agent-pane): no system call on the key path to judge an event's p…
lawrencecchen Oct 30, 2026
d9c4ed9
test(agent-pane): a posted mouse move fills the table; no verdict kep…
lawrencecchen Oct 30, 2026
f6b2533
fix(agent-pane): prime the posting-process table from any event; keep…
lawrencecchen Oct 30, 2026
3f13e1d
chore(arch-ratchet): owner and expiry for the EventSourceProcesses bl…
lawrencecchen Oct 30, 2026
38ce555
test(agent-pane): the exit watch fires on exec; bound the kernel wait…
lawrencecchen Oct 30, 2026
ab90e14
fix(agent-pane): the posting-process watch also fires on exec; fail c…
lawrencecchen Oct 30, 2026
ff83b4e
chore(agent-pane): drop an unneeded nonisolated(unsafe) on the exit w…
lawrencecchen Oct 30, 2026
97fd679
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/stall-p…
lawrencecchen Oct 30, 2026
092990c
Merge remote-tracking branch 'origin/feat-cmux-next' into HEAD
lawrencecchen Oct 31, 2026
c393763
Merge remote-tracking branch 'origin/feat-cmux-next' into fix/stall-p…
lawrencecchen Oct 31, 2026
4cc0a03
refactor(agent-pane): the posting-process table lives in the person-i…
lawrencecchen Oct 31, 2026
7427601
Merge remote-tracking branch 'origin/feat-cmux-next' into refresh2-28065
lawrencecchen Oct 31, 2026
072f126
Merge remote-tracking branch 'origin/feat-cmux-next' into dirty/28065
lawrencecchen Oct 31, 2026
f1eed23
test(agent-pane): the real event-source read names other users' proce…
lawrencecchen Nov 1, 2026
62fc9e9
fix(agent-pane): read an event source's start time with sysctl(KERN_P…
lawrencecchen Nov 1, 2026
8a24c2b
Merge remote-tracking branch 'origin/feat-cmux-next' into pidpath-merge
lawrencecchen Nov 1, 2026
dc20356
fix(agent-pane): the file chooser's gesture record passes the event-s…
lawrencecchen Nov 1, 2026
4b705a7
Merge commit 'fd9dbf936f3b7f239439ec701d0a5c40a8f3edb6' into pidpath-…
lawrencecchen Nov 1, 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
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,44 @@ enum AgentPanePersonInput {
"/System/Library/CoreServices/AssistiveControl.app/",
]

static func isPerson(_ event: NSEvent) -> Bool {
/// `sources`: the posting processes' paths (the pane's ``AgentPaneView/eventSources``). No system call: a process not
/// read yet is not the person's (the safe default: a gesture is never granted on a guess) until
/// ``EventSourceProcesses`` has read it off the main thread.
static func isPerson(_ event: NSEvent, sources: EventSourceProcesses) -> Bool { // perf-hot: 2us key-dispatch (every key- and mouse-down in an agent pane)
guard let source = event.cgEvent?.getIntegerValueField(.eventSourceUnixProcessID) else { return false }
if source == 0 { return true }
#if DEBUG
if source == Int64(getpid()) { return true }
if source == Int64(EventSourceProcesses.ownPID) { return true }
#endif
guard let path = executablePath(pid_t(truncatingIfNeeded: source)) else { return false }
guard source > 0, let pid = pid_t(exactly: source), let path = sources.path(of: pid) else { return false }
return personServices.contains { path.hasPrefix($0) }
}

/// Whether the event AppKit is dispatching now (a menu choice, a shortcut, a drop) is the
/// person's; none (a call from outside an event) is not.
@MainActor static var currentIsPerson: Bool { NSApp.currentEvent.map(isPerson) ?? false }
@MainActor static func currentIsPerson(sources: EventSourceProcesses) -> Bool {
NSApp.currentEvent.map { isPerson($0, sources: sources) } ?? false
}

/// Records the gesture of a native choice (a menu item, a shortcut, a drop) only when the event
/// in dispatch is the person's: a posted click or key, or a scripted run, grants nothing.
/// `answer`: the native permission shortcut, which may answer a prompt as a click does.
@MainActor static func record(_ gestures: AgentPaneUserGestures, if byPerson: Bool = true, answer: Bool = false,
event: NSEvent? = NSApp.currentEvent) {
if byPerson, let event, isPerson(event) { gestures.record(click: answer) }
@MainActor static func record(_ gestures: AgentPaneUserGestures, sources: EventSourceProcesses, if byPerson: Bool = true,
answer: Bool = false, event: NSEvent? = NSApp.currentEvent) {
if byPerson, let event, isPerson(event, sources: sources) { gestures.record(click: answer) }
}

/// The pid of the process that posted `event`, when it is another process (not the HID system's
/// 0, not this process): the pid ``EventSourceProcesses`` reads. No system call.
static func postingPID(_ event: NSEvent) -> pid_t? {
guard let source = event.cgEvent?.getIntegerValueField(.eventSourceUnixProcessID), source > 0,
source != Int64(EventSourceProcesses.ownPID) else { return nil }
return pid_t(exactly: source)
}

static func executablePath(_ pid: pid_t) -> String? {
/// The executable path of process `pid` (`proc_pidpath`), nil when unknown. A system call: only
/// ``EventSourceProcesses``'s read calls it, on the table's own queue, never on the key path.
nonisolated static func executablePath(_ pid: pid_t) -> String? {
var buffer = [CChar](repeating: 0, count: Int(MAXPATHLEN) * 4)
guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return nil }
return String(cString: buffer)
Expand All @@ -63,16 +78,19 @@ enum AgentPanePersonInput {
public struct PersonInputCheck: Sendable {
static let windowServer = "/System/Library/PrivateFrameworks/SkyLight.framework/Versions/A/Resources/WindowServer"

public nonisolated init() {}
private let sources: EventSourceProcesses

/// A check that reads the posting processes' paths from `sources` (`AppServices.personInput.sources`).
public nonisolated init(sources: EventSourceProcesses) { self.sources = sources }

/// Whether `event` is the person's own input from the HID system.
@MainActor public func isPerson(_ event: NSEvent) -> Bool {
@MainActor public func isPerson(_ event: NSEvent) -> Bool { // perf-hot: 2us key-dispatch (every key and mouse event of the app, PersonInputLedger)
guard let cgEvent = event.cgEvent,
cgEvent.getIntegerValueField(.eventSourceStateID) == Int64(CGEventSourceStateID.hidSystemState.rawValue) else { return false }
let source = cgEvent.getIntegerValueField(.eventSourceUnixProcessID)
if source == 0 { return true }
guard source > 0, source != Int64(getpid()) else { return false }
return AgentPanePersonInput.executablePath(pid_t(truncatingIfNeeded: source)) == Self.windowServer
guard source > 0, source != Int64(EventSourceProcesses.ownPID), let pid = pid_t(exactly: source) else { return false }
return sources.path(of: pid) == Self.windowServer
}
}

Expand All @@ -85,23 +103,40 @@ public struct PersonInputCheck: Sendable {
/// What a page counts as its gesture (``PageWKWebView``'s key and mouse events), and more.
public static let types: NSEvent.EventTypeMask = [.leftMouseDown, .leftMouseUp, .leftMouseDragged, .rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp, .keyDown, .keyUp]
/// Events that are no gesture but still name their poster: each queues the read of a process the
/// table does not hold yet (``EventSourceProcesses/prime(_:)``), so a remote session's mouse moves
/// fill the table before its first click.
public static let primingTypes: NSEvent.EventTypeMask = [.mouseMoved, .scrollWheel, .rightMouseDragged, .otherMouseDragged,
.flagsChanged]
/// The latest input: its `timestamp` (system uptime) and whether it was the person's.
public private(set) var last: (uptime: TimeInterval, person: Bool)?
private let check: @MainActor (NSEvent) -> Bool
/// The app's one table of the processes that post input, which the priming events fill. The composition
/// root builds it with the ledger (`AppServices.personInput`) and hands it to every other person check
/// (``PersonInputCheck``, each agent pane through ``AgentPaneShared``).
public let sources: EventSourceProcesses
private var monitor: Any?

public init(check: @escaping @MainActor (NSEvent) -> Bool = { PersonInputCheck().isPerson($0) }) {
/// `sources`: the table the priming events fill (tests that pass none get their own).
public init(check: @escaping @MainActor (NSEvent) -> Bool, sources: EventSourceProcesses = EventSourceProcesses()) {
self.check = check
self.sources = sources
}

/// The ledger of ``PersonInputCheck`` over `sources` (`AppServices.personInput`).
public convenience init(sources: EventSourceProcesses) {
let check = PersonInputCheck(sources: sources)
self.init(check: { check.isPerson($0) }, sources: sources)
}

public func start() {
guard monitor == nil else { return }
monitor = NSEvent.addLocalMonitorForEvents(matching: Self.types) { [weak self] event in
monitor = NSEvent.addLocalMonitorForEvents(matching: Self.types.union(Self.primingTypes)) { [weak self] event in
// AppKit calls a local monitor on the main thread; anywhere else, nothing is recorded, so no
// grant is backed (fail closed).
guard Thread.isMainThread else { return event }
// main-proof: guarded by Thread.isMainThread above (AppKit calls local monitors on main; it records before the page sees the event)
MainActor.assumeIsolated { self?.record(event) }
MainActor.assumeIsolated { self?.observe(event) }
return event
}
}
Expand All @@ -111,6 +146,15 @@ public struct PersonInputCheck: Sendable {
monitor = nil
}

/// One event the monitor saw: a gesture type is recorded; any other queues its poster's read.
public func observe(_ event: NSEvent) { // perf-hot: 2us key-dispatch (every key and mouse event of the app, mouse moves included)
if Self.types.contains(NSEvent.EventTypeMask(type: event.type)) {
record(event)
} else if let pid = AgentPanePersonInput.postingPID(event) {
sources.prime(pid)
}
}

public func record(_ event: NSEvent) {
last = (event.timestamp > 0 ? event.timestamp : ProcessInfo.processInfo.systemUptime, check(event))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
public import CmuxNextDesign

/// The app-wide objects every agent pane view shares, built once in the composition root and handed to each
/// ``AgentPaneView`` the agent tabs make, so the app holds one value for them, not one field per object.
///
/// - `openPanel`: the app's made-ahead open panel (`AppServices.openPanel`) for Add Files, Attach and a page's
/// file input; nil (tests) shows no file picker.
/// - `eventSources`: the app's table of processes that post input (`AppServices.personInput.sources`): a pane
/// judges whether a key or click is the person's from it, never by a system call per event.
public struct AgentPaneShared {
public let openPanel: MadeAheadOpenPanel?
public let eventSources: EventSourceProcesses

/// The defaults (tests): no open panel, and a posting-process table of the pane's own.
public init(openPanel: MadeAheadOpenPanel? = nil, eventSources: EventSourceProcesses = EventSourceProcesses()) {
self.openPanel = openPanel
self.eventSources = eventSources
}
}

extension AgentPaneView {
/// A pane over the app's shared objects (``AgentPaneShared``); the other parameters are as in the designated init.
public convenience init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The shared initializer drops testRoot, chromium, and terminations from AgentPaneView's designated initializer. Callers using shared pane objects therefore silently get the default page-host and termination behavior and cannot inject test dependencies; add and forward these parameters.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneShared.swift, line 23:

<comment>The shared initializer drops `testRoot`, `chromium`, and `terminations` from `AgentPaneView`'s designated initializer. Callers using shared pane objects therefore silently get the default page-host and termination behavior and cannot inject test dependencies; add and forward these parameters.</comment>

<file context>
@@ -0,0 +1,28 @@
+
+extension AgentPaneView {
+    /// A pane over the app's shared objects (``AgentPaneShared``); the other parameters are as in the designated init.
+    public convenience init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped,
+                             pageHost: Bool = false, shared: AgentPaneShared) {
+        self.init(model: model, source: source, renderRate: renderRate, pageHost: pageHost, openPanel: shared.openPanel,
</file context>

pageHost: Bool = false, shared: AgentPaneShared) {
self.init(model: model, source: source, renderRate: renderRate, pageHost: pageHost, openPanel: shared.openPanel,
eventSources: shared.eventSources)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ extension AgentPaneView {
"permissionRetry", "permissionRevoke", "permissionRefresh"]
guard allowed.contains(command) else { return }
// The person's shortcut is the gesture its answer uses; a posted key or script grants nothing.
AgentPanePersonInput.record(model.transport.gestures, if: byPerson, answer: true)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources, if: byPerson, answer: true)
send(command)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ extension AgentPaneView {
/// in the file input says about where they came from, for `chat.attach.source`.
func installContextMenu() {
let dropped: @MainActor ([URL]) -> Void = { [weak self] urls in
guard let self, AgentPanePersonInput.currentIsPerson else { return }
AgentPanePersonInput.record(model.transport.gestures)
guard let self, AgentPanePersonInput.currentIsPerson(sources: eventSources) else { return }
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
model.droppedPaths.stash(urls, kind: .drop)
let drag = NSPasteboard(name: .drag)
model.droppedPaths.stashSource(
Expand All @@ -130,7 +130,7 @@ extension AgentPaneView {
// Read as the paste goes to WebKit: the pasteboard WebKit hands the page. As for a drop, only
// the person's paste (a posted Cmd-V records nothing).
let pasted: @MainActor () -> Void = { [weak self] in
guard let self, AgentPanePersonInput.currentIsPerson else { return }
guard let self, AgentPanePersonInput.currentIsPerson(sources: eventSources) else { return }
let urls = AgentPaneDroppedPaths.fileURLs(on: .general)
model.droppedPaths.stash(urls, kind: .paste)
model.droppedPaths.stashSource(
Expand All @@ -146,7 +146,7 @@ extension AgentPaneView {
// the upload of every picked file (Retry on each tile).
let chosen: @MainActor ([URL]) -> Void = { [weak self] urls in
guard let self else { return }
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
model.droppedPaths.stash(urls, kind: .choose)
model.droppedPaths.stashSource(urls, items: urls.count, claimed: nil, kind: .choose)
}
Expand Down Expand Up @@ -284,20 +284,20 @@ extension AgentPaneView {
/// Retry, Edit and Resend, Open Image and a path's Open: the page's own actions, with the menu choice as the gesture.
private func runMessageAction(_ name: String, _ params: [String: String]) {
guard let data = try? JSONSerialization.data(withJSONObject: params) else { return }
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
evaluateScript("window.cmuxAcpmuxActions?.['\(name)']?.(\(String(decoding: data, as: UTF8.self)));")
}

/// Open Link: outside the pane, on the rule a click on the link follows.
private func openLink(_ url: URL) {
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
_ = AgentPaneNavigation.openOutside(url, gestures: model.transport.gestures) { self.openURL($0, []) }
}

/// Fork from Here: the turn footer's fork, through the page's own action. The menu choice is
/// the user's gesture, as a native permission shortcut is.
private func fork(through seq: Int) {
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
if NSEvent.modifierFlags.contains(.option) {
popUpForkMenu(through: seq)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ extension AgentPaneView {
///
/// The request is the user's gesture, as a native permission shortcut is.
public func forkConversation(to destination: AgentForkDestination, offerDestinations: Bool = false) {
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
if offerDestinations, let event = NSApp.currentEvent, event.type == .leftMouseUp, event.modifierFlags.contains(.option) {
popUpForkMenu()
} else {
Expand All @@ -17,7 +17,7 @@ extension AgentPaneView {
}

public func chooseForkDestination() {
AgentPanePersonInput.record(model.transport.gestures)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources)
popUpForkMenu()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ extension AgentPaneView {
// A legacy bridge has no PageCallContext. The menu choice itself is the person's
// gesture, so renew the one-use credit before a page action such as a user-only setting
// write runs after this reply; a script cannot synthesize this AppKit event.
if picked != nil, let self { AgentPanePersonInput.record(self.model.transport.gestures) }
if picked != nil, let self { AgentPanePersonInput.record(self.model.transport.gestures, sources: self.eventSources) }
reply(["ok": true, "value": ["picked": picked.map { $0 as Any } ?? NSNull()]])
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ extension AgentPaneView {
/// Keep this synchronous: a later task no longer has the selection's current event.
func invokeTabRowTool(_ id: String, menuItemID: String?, event: NSEvent? = NSApp.currentEvent) {
guard model.tabRow.nativeToolsEnabled, let pageEvents else { return }
AgentPanePersonInput.record(model.transport.gestures, event: event)
AgentPanePersonInput.record(model.transport.gestures, sources: eventSources, event: event)
pageEvents.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ extension AgentPaneView {
// Repeats belong to the original press, whose DOM proof may still be redeeming.
// A new page event revokes it, even when posted input cannot grant a fresh witness.
model.transport.gestures.recordQuestionReturn(false)
guard AgentPanePersonInput.isPerson(event) else { return }
guard AgentPanePersonInput.isPerson(event, sources: eventSources) else { return }
model.transport.gestures.record(click: event.type != .keyDown)
if event.type == .keyDown { model.transport.gestures.recordQuestionReturn(Self.isReturnKey(event)) }
}
Expand All @@ -195,7 +195,7 @@ extension AgentPaneView {
guard chromiumPageContains(eventWindow.convertPoint(toScreen: event.locationInWindow), eventWindow: eventWindow) else { return }
}
model.transport.gestures.recordQuestionReturn(false)
guard AgentPanePersonInput.isPerson(event) else { return }
guard AgentPanePersonInput.isPerson(event, sources: eventSources) else { return }
model.transport.gestures.record(click: event.type != .keyDown)
if event.type == .keyDown { model.transport.gestures.recordQuestionReturn(Self.isReturnKey(event)) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ public final class AgentPaneView: NSView {
let savePanel = AgentPaneSavePanel()
/// The app's made-ahead open panel (Add Files, Attach, a page's file input); nil: no file picker (tests).
private let openPanel: MadeAheadOpenPanel?
/// The app's table of processes that post input (`AppServices.personInput.sources`): whether a key or click
/// is the person's (``AgentPanePersonInput``) is read from it, never by a system call per event.
let eventSources: EventSourceProcesses
/// The page's file chooser over ``openPanel`` (the web view holds its UI delegate weakly).
let fileChooser: PageOpenPanel?
/// On the shared page host (`cmux-page://cmux.agent/`, the `agent.pageHost` tunable): the page
Expand Down Expand Up @@ -164,12 +167,15 @@ public final class AgentPaneView: NSView {
/// panes so one Reload reaches every chat that ended with it; nil: ``processGroups``' (tests pass their own).
/// - openPanel: The app's made-ahead open panel (`AppServices.openPanel`) for Add Files, Attach and a page's
/// file input, warmed when the pane reaches a window; nil (tests) shows no file picker and warms none.
/// - eventSources: The app's table of processes that post input (`AppServices.personInput.sources`); tests get their own.
public init?(model: AgentPaneModel, source: AgentPaneSource? = nil, renderRate: AgentPaneRenderRate = .capped,
pageHost: Bool = false, testRoot: Bool = false, chromium: (any PageChromiumHosting)? = PageWebView.chromiumHosting,
openPanel: MadeAheadOpenPanel? = nil, terminations: AgentPageTerminations? = nil) {
openPanel: MadeAheadOpenPanel? = nil, terminations: AgentPageTerminations? = nil,
eventSources: EventSourceProcesses = EventSourceProcesses()) {
self.terminations = terminations ?? Self.processGroups.terminations
guard let source = source ?? Self.bundledPage.map({ AgentPaneSource.bundled($0) }) else { return nil }
self.openPanel = openPanel
self.eventSources = eventSources
fileChooser = openPanel.map { PageOpenPanel(openPanel: $0) }
self.model = model
self.source = source
Expand Down
Loading
Loading