Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
45174eb
feat: add React acpmux agent pane
teamleaderleo Sep 30, 2026
be2865c
fix: encode acpmux web rows safely
teamleaderleo Sep 30, 2026
32cf7f6
feat: let React connect directly to acpmux
teamleaderleo Sep 30, 2026
f5c5c8e
docs: clarify acpmux web token scope
teamleaderleo Sep 30, 2026
a379e94
fix: reconcile direct acpmux transcript events
teamleaderleo Sep 30, 2026
1484546
fix: preserve direct acpmux events during attach
teamleaderleo Sep 30, 2026
3a3ec16
fix: harden direct acpmux pane lifecycle
teamleaderleo Sep 30, 2026
30f7385
fix: ignore stale acpmux attach replies
teamleaderleo Sep 30, 2026
d6f5f36
fix: persist new sessions before prompting
teamleaderleo Sep 30, 2026
cdb865c
fix: ignore stale session selections
teamleaderleo Sep 30, 2026
f09887f
fix: prevent stale attaches from changing sessions
teamleaderleo Sep 30, 2026
429f932
fix: recover when acpmux purges a session
teamleaderleo Sep 30, 2026
74231d5
fix: clear stale state after session purge
teamleaderleo Sep 30, 2026
a35df33
fix: refresh direct acpmux web resources
teamleaderleo Sep 30, 2026
a974f73
fix: harden acpmux preview and transcript layout
teamleaderleo Sep 30, 2026
8fc1387
fix: complete acpmux history and preview parity
teamleaderleo Sep 30, 2026
6e1c914
fix: preserve queued prompt and session defaults
teamleaderleo Sep 30, 2026
f9c014d
fix: keep transcript rows and recordings consistent
teamleaderleo Sep 30, 2026
92edc16
perf: cache prepared markdown blocks
teamleaderleo Sep 30, 2026
0b8b161
fix: refresh session picker on daemon changes
teamleaderleo Sep 30, 2026
ce60b80
fix: align direct transcript state and web assets
teamleaderleo Sep 30, 2026
64ccaa7
fix: preserve turn summaries across protocol events
teamleaderleo Sep 30, 2026
610acc3
fix: complete direct acpmux pane wiring
teamleaderleo Sep 30, 2026
4d91ad2
fix: guard history paging and mixed activity rows
teamleaderleo Sep 30, 2026
08f18e3
fix: count edited-file replacement lines
teamleaderleo Sep 30, 2026
11eb731
fix: pair turn summaries across daemon events
teamleaderleo Sep 30, 2026
5dd764c
fix: restore acpmux transcript layout styles
teamleaderleo Sep 30, 2026
23d3c47
fix: reopen transcript at latest session turn
teamleaderleo Sep 30, 2026
7eea6ae
fix: namespace agent renderer setting under app
teamleaderleo Sep 30, 2026
ac93077
fix: nest renderer setting in app schema
teamleaderleo Sep 30, 2026
adb0403
feat: route pane native actions through host
teamleaderleo Sep 30, 2026
34a2c44
build: refresh acpmux pane bundle
teamleaderleo Sep 30, 2026
f5f5f59
fix: keep folded transcript geometry exact
teamleaderleo Sep 30, 2026
89fa041
fix: align acpmux reducer edge cases
teamleaderleo Sep 30, 2026
9a712ba
perf: coalesce streaming thought rows
teamleaderleo Sep 30, 2026
6ffcf31
fix: preserve duplicate queued prompts
teamleaderleo Sep 30, 2026
d822043
fix: retain implicit turns in history replay
teamleaderleo Sep 30, 2026
7dd7dbb
fix: preserve turn boundaries in reducer
teamleaderleo Sep 30, 2026
81fa60b
fix: match turn results by id
teamleaderleo Sep 30, 2026
00937c1
fix: keep composer ready across live snapshots
teamleaderleo Oct 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
1 change: 1 addition & 0 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ jobs:
- name: Build and test Agent Session web resources
run: |
set -euo pipefail
bun install --cwd webviews --frozen-lockfile
bun install --frozen-lockfile
bun run agent-session-web:build
bun run agent-session-web:test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ public final class AcpmuxChatSessionModel {
}
}

/// Returns the daemon's authenticated WebSocket endpoint for a direct web renderer.
/// The model still owns daemon startup and reconnect; the web view owns the ACP stream.
public func webSocketEndpoint() async throws -> AcpmuxWebSocketEndpoint {
guard let api else {
throw JSONRPCError(code: -32001, message: "acpmux is not connected")
}
return try await api.webSocketEndpoint()
}

/// Disconnects and stops reconnecting.
public func stop() {
connectionTask?.cancel()
Expand Down Expand Up @@ -187,6 +196,17 @@ public final class AcpmuxChatSessionModel {
try? await attach(newSessionId, api: api, resume: false)
}

/// Records a session selected by a secondary client without attaching this model.
///
/// The React renderer owns its own acpmux connection. This method keeps the panel's
/// persisted selection current while leaving the native model's connection untouched.
/// - Parameter sessionId: The acpmux session id to persist.
public func rememberSession(sessionId newSessionId: String) {
guard !newSessionId.isEmpty, newSessionId != sessionId else { return }
sessionId = newSessionId
onSessionIdChanged?(newSessionId)
}

/// Creates a session on `harness` and selects it.
/// - Returns: The new session id, or `nil` when creation failed.
@discardableResult
Expand Down Expand Up @@ -346,6 +366,18 @@ public final class AcpmuxChatSessionModel {
Task { try? await api.setModel(sessionId: sessionId, modelId: modelId) }
}

/// Selects an ACP mode exposed by the active harness.
public func setMode(_ modeId: String) {
guard let api, let sessionId else { return }
Task { try? await api.setMode(sessionId: sessionId, modeId: modeId) }
}

/// Changes a harness configuration option, such as reasoning effort.
public func setConfigOption(id: String, value: JSONValue) {
guard let api, let sessionId else { return }
Task { try? await api.setConfigOption(sessionId: sessionId, configId: id, value: value) }
}

/// Replaces the transcript with `records`, for DEBUG performance measurements only.
public func debugReplaceTranscript(with records: [AcpmuxEventRecord]) {
reducer = TranscriptReducer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Foundation
/// The acpmux requests the chat pane uses, as a seam so the session model can be tested
/// against a fake daemon.
public protocol AcpmuxSessionAPI: Sendable {
/// The authenticated loopback WebSocket endpoint for browser clients.
func webSocketEndpoint() async throws -> AcpmuxWebSocketEndpoint
/// Daemon notifications. Finishes when the connection closes.
var notifications: AsyncStream<JSONRPCNotification> { get }
/// `_acpmux/watch {enabled:true}`; returns the current session list.
Expand Down Expand Up @@ -32,10 +34,39 @@ public protocol AcpmuxSessionAPI: Sendable {
func harnessCatalog() async throws -> AcpmuxHarnessCatalog
/// `session/set_model`.
func setModel(sessionId: String, modelId: String) async throws
/// `session/set_mode`, when the harness exposes ACP modes.
func setMode(sessionId: String, modeId: String) async throws
/// `session/set_config_option`, used for reasoning effort and similar options.
func setConfigOption(sessionId: String, configId: String, value: JSONValue) async throws
/// Closes the connection.
func close() async
}

/// A daemon WebSocket endpoint safe to hand to a local web view for one launch.
public struct AcpmuxWebSocketEndpoint: Sendable, Equatable, Codable {
public let endpoint: String
public let token: String

public init(endpoint: String, token: String) {
self.endpoint = endpoint
self.token = token
}
}

public extension AcpmuxSessionAPI {
func webSocketEndpoint() async throws -> AcpmuxWebSocketEndpoint {
throw JSONRPCError(code: -32601, message: "WebSocket endpoint is unavailable")
}

func setMode(sessionId _: String, modeId _: String) async throws {
throw JSONRPCError(code: -32601, message: "session/set_mode is unavailable")
}

func setConfigOption(sessionId _: String, configId _: String, value _: JSONValue) async throws {
throw JSONRPCError(code: -32601, message: "session/set_config_option is unavailable")
}
}

/// ``AcpmuxSessionAPI`` over a live ``JSONRPCClient``.
public struct AcpmuxRPCSessionAPI: AcpmuxSessionAPI {
private let client: JSONRPCClient
Expand All @@ -47,6 +78,23 @@ public struct AcpmuxRPCSessionAPI: AcpmuxSessionAPI {

public var notifications: AsyncStream<JSONRPCNotification> { client.notifications }

public func webSocketEndpoint() async throws -> AcpmuxWebSocketEndpoint {
let status = try await client.request("_acpmux/status", params: [String: String](), as: StatusResult.self)
guard let webURL = status.webURL,
var components = URLComponents(string: webURL),
let token = components.queryItems?.first(where: { $0.name == "token" })?.value,
!token.isEmpty,
let scheme = components.scheme else {
throw JSONRPCError(code: -32001, message: "acpmux did not publish an authenticated WebSocket endpoint")
}
components.scheme = scheme == "https" ? "wss" : "ws"
components.queryItems = components.queryItems?.filter { $0.name != "token" }
guard let endpoint = components.url?.absoluteString else {
throw JSONRPCError(code: -32001, message: "acpmux published an invalid WebSocket endpoint")
}
return AcpmuxWebSocketEndpoint(endpoint: endpoint, token: token)
}

/// Sends `initialize`.
public func initialize(clientName: String, version: String) async throws {
_ = try await client.request("initialize", params: InitializeParams(
Expand Down Expand Up @@ -142,6 +190,18 @@ public struct AcpmuxRPCSessionAPI: AcpmuxSessionAPI {
_ = try await client.request("session/set_model", params: ["sessionId": sessionId, "modelId": modelId])
}

public func setMode(sessionId: String, modeId: String) async throws {
_ = try await client.request("session/set_mode", params: ["sessionId": sessionId, "modeId": modeId])
}

public func setConfigOption(sessionId: String, configId: String, value: JSONValue) async throws {
_ = try await client.request("session/set_config_option", params: JSONValue.object([
"sessionId": .string(sessionId),
"configId": .string(configId),
"value": value,
]))
}

public func close() async {
await client.close()
}
Expand All @@ -168,6 +228,11 @@ public struct AcpmuxRPCSessionAPI: AcpmuxSessionAPI {
var sessions: [AcpmuxSessionSummary]
}

private struct StatusResult: Decodable, Sendable {
var webURL: String?
enum CodingKeys: String, CodingKey { case webURL = "webUrl" }
}

private struct EventsResult: Decodable, Sendable {
var events: [AcpmuxEventRecord]
var hasMore: Bool?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Versioned host messages for the direct React acpmux client.
public struct AcpmuxWebBridgeProtocol {
public let version: Int

public init(version: Int = 1) {
self.version = version
}
}

/// Configuration returned by the host after the page is ready. The daemon bearer
/// token is handed to this local web-view launch and is never persisted by Swift.
public struct AcpmuxWebHostHandshake: Codable, Sendable, Equatable {
public let protocolVersion: Int
public let transport: String
public let endpoint: String
public let token: String
public let sessionId: String?
public let newSessionHarness: String?
public let workingDirectory: String?

public init(endpoint: String, token: String, sessionId: String?, newSessionHarness: String? = nil, workingDirectory: String? = nil) {
protocolVersion = AcpmuxWebBridgeProtocol().version
transport = "acpmux-websocket"
self.endpoint = endpoint
self.token = token
self.sessionId = sessionId
self.newSessionHarness = newSessionHarness
self.workingDirectory = workingDirectory
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ public struct AcpmuxSessionSummary: Sendable, Hashable, Codable, Identifiable {
public var model: String?
/// The reasoning effort.
public var effort: String?
/// Harness configuration choices returned by attach, such as effort.
public var configOptions: [AcpmuxConfigOption]?
/// Mode choices and the selected mode returned by attach.
public var modes: AcpmuxModeCatalog?
/// Whether output arrived while nobody was attached.
public var unread: Bool?
/// The peer daemon name for remote sessions.
Expand Down Expand Up @@ -70,6 +74,44 @@ public struct AcpmuxSessionSummary: Sendable, Hashable, Codable, Identifiable {
public var isWorking: Bool { status == "running" || status == "waiting" || turn != nil }
}

/// A selectable session configuration option.
public struct AcpmuxConfigOption: Sendable, Hashable, Codable, Identifiable {
/// Stable config id passed to `session/set_config_option`.
public var id: String
/// Display name.
public var name: String?
/// Harness category, such as `thought_level`.
public var category: String?
/// Current value.
public var currentValue: String?
/// Available values.
public var options: [Value]

public struct Value: Sendable, Hashable, Codable, Identifiable {
/// Display name.
public var name: String?
/// Value passed back to acpmux.
public var value: String
/// Stable value id.
public var id: String { value }
}
}

/// ACP modes exposed by a harness.
public struct AcpmuxModeCatalog: Sendable, Hashable, Codable {
/// Modes exposed by the harness.
public var availableModes: [Mode]
/// Current mode id.
public var currentModeId: String?

public struct Mode: Sendable, Hashable, Codable, Identifiable {
/// Stable mode id.
public var id: String
/// Display name.
public var name: String?
}
}

/// The running turn attached to a session summary.
public struct AcpmuxRunningTurn: Sendable, Hashable, Codable {
/// Turn start time in Unix milliseconds.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import CmuxAcpmux
import Foundation
import Testing

@Suite struct AcpmuxWebBridgeProtocolTests {
@Test func handshakeRoundTrips() throws {
let handshake = AcpmuxWebHostHandshake(
endpoint: "ws://127.0.0.1:47811",
token: "launch-token",
sessionId: "session-1"
)
let encoded = try JSONEncoder().encode(handshake)
let decoded = try JSONDecoder().decode(AcpmuxWebHostHandshake.self, from: encoded)
#expect(decoded == handshake)
#expect(decoded.protocolVersion == 1)
#expect(decoded.transport == "acpmux-websocket")
}
}
5 changes: 5 additions & 0 deletions Packages/macOS/CmuxAcpmux/WebBridgeREADME.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Acpmux web bridge v1

`AcpmuxWebBridgeProtocol.swift` defines the small versioned host contract between Swift and the React pane. Swift starts or finds acpmux, then returns its authenticated loopback WebSocket endpoint, a per-launch bearer token, and the selected session id. React connects directly to acpmux, sends `_acpmux/attach {eventStream: true}`, resumes with `afterSeq`, pages older records through `_acpmux/events {beforeSeq}`, folds event records into rows, and sends ACP and `_acpmux/*` actions. Swift remains the WKWebView host and owns native-only actions; acpmux owns session state and business logic.

The React preview at `webviews/src/agent-session/acpmux-preview` uses recordings of the same acpmux event stream with a mock host handshake. The daemon WebSocket listener supports query-token authentication, so Safari/WKWebView can connect without setting an Authorization header.
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,21 @@ enum CmuxEmbeddedConfigSchema {
"default": "automatic",
"description": "Dock and app switcher icon style."
},
"agentSession": {
"x-cmux-scopes": ["global"],
"title": "agentSession",
"type": "object",
"additionalProperties": false,
"description": "Agent session pane renderer settings.",
"properties": {
"renderer": {
"type": "string",
"enum": ["native", "typescript"],
"default": "native",
"description": "Renderer for new agent session panes. TypeScript uses the React web renderer over the acpmux bridge."
}
}
},
"windowTitleTemplate": {
"type": "string",
"default": "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import Foundation

/// Settings under the dotted-id prefix `app.*` — user-facing app behavior.
public struct AppCatalogSection: SettingCatalogSection {
/// Renderer used for newly opened acpmux agent-session panes.
public let agentSessionRenderer = JSONKey<String>(
id: "app.agentSession.renderer",
defaultValue: "native"
)
public let appearance = DefaultsKey<AppearanceMode>(
id: "app.appearance",
defaultValue: .system,
Expand Down
Loading
Loading