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
86c6f43
test(agent chat): a reopened chat shows its whole history, with no ba…
teamleaderleo Oct 21, 2026
69936d1
cmux-next agent chat: a chat opened in a new tab draws from the tail …
teamleaderleo Oct 21, 2026
3d29d6c
test(agent chat): a chat opened in a new tab draws from the host's ta…
teamleaderleo Oct 21, 2026
a68aefc
style: oxfmt the new-tab tail test
teamleaderleo Oct 21, 2026
17fdd69
Merge commit '5fe3814a23f448ba053f6b5668bbd657337aa7ef' into cmux/new…
teamleaderleo Oct 21, 2026
323b08a
Merge commit '2df94d16520b2b69652aa19e7ff995c60e4d949e' into cmux/new…
teamleaderleo Oct 21, 2026
2c5019e
cmux-next: the sign-out comment fits on one line (CloudService is ove…
teamleaderleo Oct 21, 2026
0fc18bd
Merge commit 'dfaaee2a93a06a1c49ac87c0e6990a734a82c023' into cmux/new…
teamleaderleo Oct 21, 2026
8cdac6e
cmux-next: a cache never holds a chat up, and the host's bounds get t…
teamleaderleo Oct 21, 2026
31f8aec
Merge commit '79ff18fee92ae610ad2c212ae97b5e6e00f5cf68' into cmux/new…
teamleaderleo Oct 21, 2026
f96c60a
Merge commit '5f84bd023b11ddb58e627aa87e791485d962ac8e' into cmux/new…
teamleaderleo Oct 21, 2026
d0f99de
fix(agent chat): a chat opened in a new tab is handed over, and the h…
teamleaderleo Oct 21, 2026
00b06d5
fix(agent chat): a closed page's tail goes to the app host alone, so …
teamleaderleo Oct 21, 2026
a45fde6
Merge commit 'f90fbd9b7db9e7f251464150a8deded4cb4c89e9' into cmux/new…
teamleaderleo Oct 21, 2026
663e486
fix(agent chat): both hosts name the two tail ops instead of matching…
teamleaderleo Oct 21, 2026
ddd0186
Merge commit '979915a6271ee162b2be88c798203015c32ffe42' into cmux/new…
teamleaderleo Oct 21, 2026
74c8a42
Merge commit '831414b6c9cf7cbefba49d409d0998f4ab5f831f' into cmux/new…
teamleaderleo Oct 21, 2026
a516370
test(agent chat): the source guard names the routing call, not just t…
teamleaderleo Oct 21, 2026
200de5c
style(agent chat): the kept-tail note wraps where the rest of the fil…
teamleaderleo Oct 21, 2026
b954f83
Merge commit 'e323f77325da77cf98d800fab9c1f58406669a94' into cmux/new…
teamleaderleo Oct 21, 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
@@ -0,0 +1,19 @@
import CmuxNextPages
import CmuxNextSettings
import Foundation

extension AgentPageProvider {
/// `chat.tail` (`sessionId`, `tail` or none) and `chat.tail.get` (`sessionId`): what a page last
/// read of a chat, held in memory for the next tab that opens it (``AgentPaneTailCache``). It is
/// the chat's, not the pane's, so the model never sees these, as with `chat.scrollAnchor`.
static func tail(_ method: String, params: JSONValue, op: String) throws -> JSONValue {
guard case .object(let body) = params, case .string(let session)? = body["sessionId"], !session.isEmpty
else { throw PageError.invalidParams(op) }
let cache = AgentPaneTailCache.shared
if method == "chat.tail.get" { return cache.tail(for: session) ?? .null }
// No tail, or one this refuses: the chat is forgotten here, so no tab draws a transcript
// this page no longer stands behind.
cache.remember(body["tail"], for: session)
return .null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps {
"pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump",
"newTab.remember", "newTab.setTemplate", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open",
"project.list", "project.icon", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.draftAttachments", "chat.draftAttachments.get",
"quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get",
"shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart",
"git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture",
"dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "dictation.audio",
Expand Down Expand Up @@ -109,6 +109,7 @@ public final class AgentPageProvider: PageProvider {
// Named, not a prefix: `scrollAnchor` treats anything but `.get` as a write, so a later
// `chat.scrollAnchor.forget` would have erased a chat's place instead of being refused.
if AgentPaneScrollAnchors.handles(method: method) { return try Self.scrollAnchor(method, params: params, op: op) }
if AgentPaneTailCache.handles(method: method) { return try Self.tail(method, params: params, op: op) }
if method.hasPrefix("chat.draftAttachments") { return try await Self.draftAttachments(method, params: params, op: op) }
let request = AgentPaneRequest(body: ["method": method, "params": params.foundationObject])
if case .unsupported = request { throw PageError.invalidParams(op) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ final class AgentPaneBridge: NSObject, WKScriptMessageHandlerWithReply {
if let reply = Self.chatPlaceReply(for: message.body) {
return replyHandler(reply, nil)
}
// What a page last read of a chat, for the next tab that opens it (``AgentPaneTailCache``):
// the chat's, not the pane's, so it never reaches the model, and never logged (it is the
// chat's text).
if let body = message.body as? [String: Any], let method = body["method"] as? String,
AgentPaneTailCache.handles(method: method) {
return replyHandler(AgentPaneTailCache.reply(to: method, params: body["params"] as? [String: Any]), nil)
}
let request = AgentPaneRequest(body: message.body)
// A page frame for the host's socket goes out on this turn when it can (no Task hop).
if case .transportSend(let connection, let frames) = request, let model = view?.model {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import CmuxNextSettings
import Foundation

extension AgentPaneTailCache {
/// The ops this seam answers, which is what both hosts branch on. Named rather than matched by
/// prefix, for the reason `AgentPaneScrollAnchors.handles` gives: ``reply(to:params:)`` treats
/// anything that is not `.get` as a write, so a later `chat.tail.forget` would have erased a
/// chat's tail instead of being refused, and on the pane's own host a prefix would swallow it
/// before ``AgentPaneRequest`` could see it.
static func handles(method: String) -> Bool {
method == "chat.tail" || method == "chat.tail.get"
}

/// `chat.tail {sessionId, tail?}` and `chat.tail.get {sessionId}` from the pane's own host
/// (``AgentPaneBridge``), which hands bridged Foundation values over: the same two ops the
/// shared page host serves in `AgentPageProvider+Tail`. New agent tabs are on the pane's own
/// host unless `agent.pageHost` is on, so both hosts serve them, from this one store.
///
/// A chat's tail is the chat's, not the pane's, so ``AgentPaneModel`` never sees these, as with
/// `chat.scrollAnchor` on the page host.
static func reply(to method: String, params: [String: Any]?) -> [String: Any] {
guard let session = params?["sessionId"] as? String, !session.isEmpty else {
return AgentPaneReply.failure(code: "native.invalid_request", message: "A chat tail needs a sessionId")
}
if method == "chat.tail.get" {
return AgentPaneReply.success(shared.tail(for: session)?.foundationObject ?? NSNull())
}
guard method == "chat.tail" else {
return AgentPaneReply.failure(code: "native.invalid_request", message: "No such chat tail request")
}
shared.remember(params?["tail"].flatMap { JSONValue(foundation: $0) }, for: session)
return AgentPaneReply.success()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
public import CmuxNextSettings
public import Foundation

/// What each agent chat's page last read of it, so the same chat opened in another tab draws at
/// once instead of after acpmux's attach round trip (Leo 2026-10-20: a chat "should be instant if
/// cached"). A pane's page keeps its own chats' tails, which is all a switch inside one pane needs;
/// a new tab is a new page, with none, so every page hands its tails here (`chat.tail`) and asks
/// for one before it attaches (`chat.tail.get`, acpmux/tailCache.ts).
///
/// IN MEMORY ONLY. Nothing is written to disk, so this adds no place a transcript persists and no
/// setting to turn off: quitting the app forgets every tail, and signing out drops them with the
/// rest of the session's state (``clear()``). What is held is bounded three ways here, so the
/// bound holds whatever a page sends: the most recent ``chats`` chats, ``events`` records of each,
/// and ``bytes`` of each (``holds(_:)``) — at most about 8 MiB in the worst case, and a small
/// fraction of that for ordinary chats, which are far under the per-chat bound.
///
/// The page bounds a tail to 256 KiB of its own before handing it over (acpmux/tailCache.ts
/// `HOST_TAIL_BYTES`), which is the number that describes real use. ``bytes`` is deliberately
/// larger rather than equal: it is a backstop against a page that bounds wrongly or not at all,
/// and the two sides count differently — the page measures `JSON.stringify().length` in UTF-16
/// units, this measures UTF-8 bytes, and one CJK character is 1 unit there and 3 bytes here. An
/// equal number would silently refuse a correct page's tail for a chat that is not in English, so
/// the chat would quietly open cold in the next tab.
///
/// The tail itself is the page's own shape, kept opaque: only the page reads it back.
@MainActor
public final class AgentPaneTailCache {
public static let shared = AgentPaneTailCache()
/// Chats whose tail is kept, as the page keeps the same number.
static let chats = 8
/// Most records one chat's tail may carry, as many as an attach page holds. A tail over this
/// is refused whole, never cut: a cut tail that still called its history exhausted would hide
/// the chat's older turns (webviews/test/reopened-chat-history.test.ts).
static let events = 400
/// Most bytes one chat's tail may carry, as its compact JSON in UTF-8. Four times the page's
/// own 256 KiB, for the reason in this type's documentation: enough that a correct page is
/// never refused in any language, small enough that a page that bounds nothing cannot grow
/// this without limit. A tail over it is refused whole, as an over-long one is.
static let bytes = 1024 * 1024

private struct Entry {
var sessionId: String
var tail: JSONValue
}

/// Least recently remembered first.
private var entries: [Entry] = []

/// ``shared`` is the one store both hosts answer from. A second one would split the cache: a
/// tail handed to one would be invisible to the other, and a chat would open cold for no
/// reason a reader could see.
private init() {}

/// The tail kept for `sessionId`, or nil when none is.
public func tail(for sessionId: String) -> JSONValue? {
entries.last { $0.sessionId == sessionId }?.tail
}

/// Keeps `tail` for chat `sessionId`; nil forgets it (the page no longer stands behind that
/// transcript). A tail this does not hold (``holds(_:)``) is refused, which leaves the chat to
/// open from acpmux as it did before — and it also drops any tail already kept for that chat,
/// because the page has read on since and this one is what it now stands behind.
public func remember(_ tail: JSONValue?, for sessionId: String) {
var kept = entries.filter { $0.sessionId != sessionId }
if let tail, Self.holds(tail) { kept.append(Entry(sessionId: sessionId, tail: tail)) }
entries = Array(kept.suffix(Self.chats))
}

/// Forgets every tail: the session's chats are no longer this person's.
public func clear() {
entries.removeAll()
}

/// Whether `tail` is a page tail this keeps: records, not too many of them, and not too large.
/// The byte bound is measured over the whole tail and not only its records, because the rest of
/// it is not bounded by a record count — a chat's `queue` carries the full text of every
/// prompt waiting to be sent, so a queued paste is what would grow this.
private static func holds(_ tail: JSONValue) -> Bool {
guard case .object(let fields) = tail, case .array(let events)? = fields["events"] else { return false }
guard !events.isEmpty, events.count <= Self.events else { return false }
var text = ""
tail.appendCompactText(to: &text)
return text.utf8.count <= Self.bytes
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CmuxNextAgentPane
import CmuxNextCloud
import CmuxNextCompat
import CmuxNextControl
Expand Down Expand Up @@ -395,6 +396,7 @@ final class CloudService {
}

func signOut() async {
AgentPaneTailCache.shared.clear() // the panes' chat tails: memory only, forgotten whole
// No ensure starts from here until the session's final endLink (the
// signed-out branch above, after auth.signOut).
link.beginEnding()
Expand Down
Loading