Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
752666e
test(cmux-next): shell mode runs ! commands in place
teamleaderleo Oct 6, 2026
502f682
feat(cmux-next): run ! commands in place instead of jumping to a term…
teamleaderleo Oct 6, 2026
28b9614
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
9bbc902
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
a3dd81a
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
29be512
fix(cmux-next): shell mode keeps a command's exit after its output ends
teamleaderleo Oct 6, 2026
988359b
next-batch: regenerate webviews formatting
github-actions[bot] Oct 6, 2026
35a393a
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
47b0623
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
988e26f
fix(cmux-next): shell field follows the macOS scrollbar setting
teamleaderleo Oct 6, 2026
3d66ead
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
9d0e3b4
fix: exempt shell field editing keys from ui rules
teamleaderleo Oct 6, 2026
2862af7
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
3188fcc
next-batch: regenerate webviews formatting
github-actions[bot] Oct 6, 2026
330715a
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 2026
73b7460
test: compare shell cwd with its physical path
teamleaderleo Oct 6, 2026
f7ecbba
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
teamleaderleo Oct 6, 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 @@ -32,6 +32,7 @@ public nonisolated struct AgentPageOps {
"newTab.remember", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.browse", "onboarding.importAndSync", "app.action",
"quick.dismiss", "quick.openInWindow", "pane.action", "pane.tabState",
"shell.run", "shell.read", "shell.stop",
"git.diff", "git.status", "file.search", "git.checkpoint.diff",
"dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings",
"transport.open", "transport.send", "transport.close", "transport.gesture", "transport.gesture.release",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply {
replyHandler(AgentPaneModel.transportReply(error), nil)
}
}
// Transport requests carry chat content and run per batch: not logged.
if !request.isTransport {
// Transport and shell requests carry chat content or commands and run often: not logged.
if !request.isTransport, !request.isShell {
logger.info("agent pane trusted message request=\(String(describing: request), privacy: .public) url=\(message.frameInfo.request.url?.absoluteString ?? "", privacy: .public)")
}
// task-owner: one page request; its reply goes back through replyHandler
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

extension AgentPaneModel {
/// The page's reply for a failed git read: the failure's code, origin,
/// details and retryable under the localized text.
static func gitFailure(_ failure: AgentPaneGitFailure) -> [String: Any] {
let details = failure.details.flatMap { try? JSONSerialization.jsonObject(with: $0, options: [.fragmentsAllowed]) }
return AgentPaneReply.failure(
code: failure.code, message: gitFailedMessage, details: details,
retryable: failure.retryable, origin: failure.origin.rawValue)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

extension AgentPaneModel {
/// Shell mode's requests (``AgentPaneShell``): `shell.run`, `shell.read` and `shell.stop`.
func respondToShell(_ request: AgentPaneRequest) async -> [String: Any] {
switch request {
case .shellRun(let command, let cwd):
// Page script cannot make a gesture: only the user's Return or click runs a command.
guard transport.gestures.consume() else {
return AgentPaneReply.failure(code: "shell.gesture_required", message: Self.shellGestureMessage)
}
do {
return AgentPaneReply.success(["id": try shell.run(command, cwd: cwd)])
} catch {
return AgentPaneReply.failure(code: "shell.failed", message: Self.shellFailureMessage(error))
}
case .shellRead(let id, let after):
do {
let chunk = try await shell.read(id, after: after)
var value: [String: Any] = ["output": chunk.output, "next": chunk.next]
if chunk.truncated { value["truncated"] = true }
if let exit = chunk.exit {
var exitValue: [String: Any] = [:]
if let code = exit.code { exitValue["code"] = Int(code) }
if let signal = exit.signal { exitValue["signal"] = Int(signal) }
value["exit"] = exitValue
}
return AgentPaneReply.success(value)
} catch {
return AgentPaneReply.failure(code: "shell.failed", message: Self.shellFailureMessage(error))
}
case .shellStop(let id):
shell.stop(id)
return AgentPaneReply.success()
default:
return AgentPaneReply.failure(code: "shell.failed", message: Self.shellFailureMessage(AgentPaneShell.Failure.unknownRun))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ public final class AgentPaneModel {
@ObservationIgnored private let allowsTabConversion: Bool
/// The host's acpmux socket for this pane (in the app the page never holds one).
@ObservationIgnored public let transport: AgentPaneTransport
/// Shell mode's commands (`shell.run`, `shell.read`, `shell.stop`).
@ObservationIgnored public let shell = AgentPaneShell()
/// The last handshake's connection, until the page opens it: used once, so the LocalApp
/// token is never kept beyond one handshake.
@ObservationIgnored private var pendingConnection: AcpmuxConnection?
Expand Down Expand Up @@ -256,6 +258,7 @@ public final class AgentPaneModel {
return AgentPaneReply.success()
case .touched:
return AgentPaneReply.success()
case .shellRun, .shellRead, .shellStop: return await respondToShell(request)
case .rememberNewTab(let agent):
guard let onRememberNewTab else { return Self.unsupported("newTab.remember") }
onRememberNewTab(agent)
Expand Down Expand Up @@ -387,14 +390,3 @@ public final class AgentPaneModel {
onCheckpointAvailability?(available)
}
}

extension AgentPaneModel {
/// The page's reply for a failed git read: the failure's code, origin,
/// details and retryable under the localized text.
static func gitFailure(_ failure: AgentPaneGitFailure) -> [String: Any] {
let details = failure.details.flatMap { try? JSONSerialization.jsonObject(with: $0, options: [.fragmentsAllowed]) }
return AgentPaneReply.failure(
code: failure.code, message: gitFailedMessage, details: details,
retryable: failure.retryable, origin: failure.origin.rawValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// The command typed after `!` so far, whole each time, while the
/// terminal that replaces the page is being made (`tab.typeAhead`).
case typeAhead(String)
/// Shell mode (`!` first in the composer or the new tab field): run
/// `command` in `cwd` (``AgentPaneShell``); answers `{id}`. Only with a
/// real gesture in the pane.
case shellRun(command: String, cwd: String?)
/// `shell.read {id, after}`: the run's output from byte `after`.
case shellRead(id: String, after: Int)
/// `shell.stop {id}`: interrupt the run's process group.
case shellStop(id: String)
/// The agent picked on the new tab screen, to remember for the next
/// new tab (`newTab.remember`).
case rememberNewTab(agent: String)
Expand Down Expand Up @@ -101,6 +109,14 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
/// Most frames in one `transport.send` (the page sends what one task wrote).
public static let maximumSendFrames = 4096

/// A shell mode request: a command can carry secrets and `shell.read` polls, so never logged.
public var isShell: Bool {
switch self {
case .shellRun, .shellRead, .shellStop: true
default: false
}
}

/// A transport request: frequent and carrying chat content, so never logged with its values.
public var isTransport: Bool {
switch self {
Expand All @@ -115,6 +131,12 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {

public static let handlerName = "agentSession"

/// A shell run's id as ``AgentPaneShell`` mints them.
static func shellID(_ params: [String: Any]?) -> String? {
guard let id = params?["id"] as? String, !id.isEmpty, id.utf8.count <= 64 else { return nil }
return id
}

/// Decodes a `WKScriptMessage.body` (a dictionary once bridged).
public init(body: Any) {
guard let object = body as? [String: Any], let method = object["method"] as? String else {
Expand Down Expand Up @@ -167,6 +189,24 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable {
} else {
self = .unsupported(method)
}
case "shell.run":
if let command = params?["command"] as? String,
!command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
command.utf8.count <= Self.maximumOpenTabText {
// A relative folder would resolve against the app's, not the chat's.
let cwd = (params?["cwd"] as? String).flatMap { $0.hasPrefix("/") && $0.utf8.count <= 4096 ? $0 : nil }
self = .shellRun(command: command, cwd: cwd)
} else {
self = .unsupported(method)
}
case "shell.read":
if let id = Self.shellID(params), let after = (params?["after"] as? NSNumber)?.intValue, after >= 0 {
self = .shellRead(id: id, after: after)
} else {
self = .unsupported(method)
}
case "shell.stop":
if let id = Self.shellID(params) { self = .shellStop(id: id) } else { self = .unsupported(method) }
case "newTab.touched":
self = .touched
case "newTab.remember":
Expand Down
Loading
Loading