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
5588d41
test: a running subagent or workflow shows what it does and what it h…
teamleaderleo Oct 14, 2026
4cca5d0
webviews: a running subagent or workflow shows what it does and what …
teamleaderleo Oct 14, 2026
7d699d4
test: a running subagent row has a stop button
teamleaderleo Oct 14, 2026
cd2cb23
feat: stop a running Claude subagent or workflow from its row
teamleaderleo Oct 14, 2026
0c3064d
Merge commit '1288f787a2f20ca37681b7433fe071084729c8e9' into leo-back…
teamleaderleo Oct 14, 2026
f3dff8a
chore: restore the catalog's base order after the merge
teamleaderleo Oct 14, 2026
1ea38b6
test: stopping a subagent leaves its row closed, and a refused stop s…
teamleaderleo Oct 14, 2026
6264d87
fix: a subagent's stop button waits for Claude and says when the stop…
teamleaderleo Oct 14, 2026
ef08415
style: format the subagent progress test
teamleaderleo Oct 14, 2026
6a186d5
fix: stop rejects without English the pane would never show
teamleaderleo Oct 14, 2026
c8d60c8
Merge commit '127ff6ca5c90061c981c83467baf766ec49c61db' into leo-back…
teamleaderleo Oct 14, 2026
0bca715
chore: restore the catalog's base order after the merge
teamleaderleo Oct 14, 2026
2fe6cc4
Merge commit '2592ce6b90eb0a0dc42e3a35c215f6af7973166c' into leo-back…
teamleaderleo Oct 14, 2026
a82d6fc
chore(next): PR autofix at 2fe6cc48ea66
Oct 14, 2026
61e4574
Merge commit '8ae8e3b15b754435133e5eacd39c5d9cb5107497' into leo-back…
teamleaderleo Oct 14, 2026
ff9ab32
test: the agent pane relays a subagent stop for its chat (cx-liswe)
teamleaderleo Oct 15, 2026
1102469
fix: the agent pane may stop a subagent of its chat (cx-liswe)
teamleaderleo Oct 15, 2026
27e9d0e
fix: mirror the subagent stop into the shared pane policy (cx-liswe)
teamleaderleo Oct 15, 2026
1cb7e58
gallery: a light window with no theme named renders light (#20794)
teamleaderleo Oct 15, 2026
4028e15
Merge commit '1cb7e587bffecb7f3e0358825af43cfc4fbab36c' into leo-back…
teamleaderleo Oct 15, 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 @@ -54,6 +54,8 @@ nonisolated extension AcpmuxPaneMethods {
"_acpmux/harness/health": (["refresh", "rescan"], []),
"_acpmux/side": (["sessionId", "chat"], []),
"_acpmux/offer_respond": (["sessionId", "offerId", "outcome", "childSessionId"], []),
// `subagentSessionId`: a child of that chat, which acpmux looks up in the chat's own map.
"_acpmux/subagent_stop": (["sessionId", "subagentSessionId"], []),
"acp.session.fork": (["sessionId", "throughSeq"], []),
"acp.trust.get": (["cwd", "sessionId"], []),
"acp.trust.set": (["cwd", "level", "sessionId"], []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ nonisolated enum AcpmuxPaneMethods {
// Dismiss or Start on a chat's session offer chip (direct.ts dismissOffer, startOffer;
// cx-807da): acpmux records the answer; Start names the child chat the pane just made.
"_acpmux/offer_respond",
// Stop a running Claude subagent or workflow of a chat in the pane's scope (direct.ts
// stopSubagent, cx-liswe): acpmux sends Claude's stop_task for the task it already holds
// for that child (claude_stdio/workflows.rs). It stops work like `_acpmux/kill`, so no
// gesture; caller params never reach Claude.
"_acpmux/subagent_stop",
]

/// The shape of a reply the relay filters itself, without relying on the daemon's redaction.
Expand Down Expand Up @@ -204,7 +209,7 @@ nonisolated enum AcpmuxPaneMethods {
"session/prompt", "session/set_mode", "session/set_config_option", "session/set_model", "session/cancel",
"_acpmux/kill", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/permission_respond", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke",
"_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", "_acpmux/side",
"_acpmux/offer_respond",
"_acpmux/offer_respond", "_acpmux/subagent_stop",
]

/// Params beside `sessionId` that name a session, which must be in the pane's scope too: New
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation
import Testing
@testable import CmuxNextAgentPane

/// Stopping a running Claude subagent or workflow at the relay boundary (the page's frames into
/// ``AgentPaneTransport``, out to acpmux): the row's stop button (direct.ts stopSubagent) reaches
/// acpmux only for a chat in the pane's scope and only in the shape the pane sends. It stops
/// work and grants nothing, like `_acpmux/kill`, so it needs no gesture.
@Suite(.serialized)
@MainActor
struct AgentPaneSubagentStopRelayTests {
static func frame(id: Int, params: String) -> String {
#"{"jsonrpc":"2.0","id":\#(id),"method":"_acpmux/subagent_stop","params":\#(params)}"#
}

@Test func aStopGoesOutOnlyForTheChatInTheShapeThePaneSends() async throws {
let server = AcpmuxStandInServer()
try await server.start()
defer { server.stop() }
let inbox = AgentPaneRelayedAllowTests.PageInbox()
let transport = AgentPaneTransport()
transport.deliver = { event, done in inbox.deliver(event.frames); done() }
let id = try await transport.open(AcpmuxConnection(url: server.url, dashboardToken: "t", localAppToken: nil))
#expect(await transport.send(connection: id, frames: [#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"#]) == nil)
transport.sessions.add("s")
let sent = { server.peers.first?.frames.filter { $0.contains("_acpmux/subagent_stop") } ?? [] }

#expect(await transport.send(connection: id, frames: [Self.frame(id: 1, params: #"{"sessionId":"other","subagentSessionId":"a"}"#)]) != nil,
"a chat outside the pane's scope")
#expect(await transport.send(connection: id, frames: [Self.frame(id: 2, params: #"{"sessionId":"s","subagentSessionId":"a","command":"ls"}"#)]) != nil,
"a param the pane never sends")
#expect(await transport.send(connection: id, frames: [Self.frame(id: 3, params: #"{"subagentSessionId":"a"}"#)]) != nil,
"no chat named")
#expect(sent().isEmpty, "a refused frame reached acpmux: \(sent())")

// No gesture: a stop grants nothing.
#expect(await transport.send(connection: id, frames: [Self.frame(id: 4, params: #"{"sessionId":"s","subagentSessionId":"a"}"#)]) == nil)
_ = await inbox.frame(containing: #""id":4"#)
let frames = sent()
#expect(frames.count == 1, "\(frames)")
let object = try JSONSerialization.jsonObject(with: Data(try #require(frames.first).utf8)) as? [String: Any]
let params = try #require(object?["params"] as? [String: Any])
#expect(params["sessionId"] as? String == "s")
#expect(params["subagentSessionId"] as? String == "a")
}
}
5 changes: 3 additions & 2 deletions cmux-tui/crates/cmux-agent-pane-policy/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"_acpmux/permission_groups", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke",
"acp.session.fork", "acp.trust.get", "acp.trust.set", "_acpmux/harness_enable",
"_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread",
"_acpmux/harness/health", "_acpmux/side", "_acpmux/offer_respond"
"_acpmux/harness/health", "_acpmux/side", "_acpmux/offer_respond", "_acpmux/subagent_stop"
],
"notifications": ["session/cancel"],
"maximum_frame_bytes": 33554432,
Expand All @@ -22,7 +22,7 @@
"session/prompt", "session/set_mode", "session/set_config_option", "session/set_model", "session/cancel",
"_acpmux/kill", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/permission_respond", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke",
"_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", "_acpmux/side",
"_acpmux/offer_respond"
"_acpmux/offer_respond", "_acpmux/subagent_stop"
],
"optionally_session_scoped": ["acp.trust.get", "acp.trust.set"],
"scoped_session_params": {"_acpmux/side": ["chat"]},
Expand Down Expand Up @@ -83,6 +83,7 @@
"_acpmux/harness/health": {"params": ["refresh", "rescan"], "acpmux": []},
"_acpmux/side": {"params": ["sessionId", "chat"], "acpmux": []},
"_acpmux/offer_respond": {"params": ["sessionId", "offerId", "outcome", "childSessionId"], "acpmux": []},
"_acpmux/subagent_stop": {"params": ["sessionId", "subagentSessionId"], "acpmux": []},
"acp.session.fork": {"params": ["sessionId", "throughSeq"], "acpmux": []},
"acp.trust.get": {"params": ["cwd", "sessionId"], "acpmux": []},
"acp.trust.set": {"params": ["cwd", "level", "sessionId"], "acpmux": []},
Expand Down
29 changes: 29 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json
Original file line number Diff line number Diff line change
Expand Up @@ -1017,5 +1017,34 @@
},
"mode_fields": null,
"breaks": true
},
{
"name": "subagent_stop known params pass",
"frame": {
"jsonrpc": "2.0",
"id": 1,
"method": "_acpmux/subagent_stop",
"params": {
"sessionId": "a",
"subagentSessionId": "t"
}
},
"mode_fields": null,
"breaks": false
},
{
"name": "subagent_stop unknown param",
"frame": {
"jsonrpc": "2.0",
"id": 1,
"method": "_acpmux/subagent_stop",
"params": {
"sessionId": "a",
"subagentSessionId": "t",
"command": "ls"
}
},
"mode_fields": null,
"breaks": true
}
]
32 changes: 32 additions & 0 deletions cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,37 @@
"a"
],
"refused": true
},
{
"name": "subagent_stop for the chat in scope passes",
"frame": {
"jsonrpc": "2.0",
"id": 4,
"method": "_acpmux/subagent_stop",
"params": {
"sessionId": "a",
"subagentSessionId": "t"
}
},
"sessions": [
"a"
],
"refused": false
},
{
"name": "subagent_stop for another chat is refused",
"frame": {
"jsonrpc": "2.0",
"id": 4,
"method": "_acpmux/subagent_stop",
"params": {
"sessionId": "b",
"subagentSessionId": "t"
}
},
"sessions": [
"a"
],
"refused": true
}
]
24 changes: 24 additions & 0 deletions schemas/gallery/env-vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@
"layout": "one"
}
},
{
"query": {
"colorScheme": "light"
},
"env": {
"locale": "en",
"theme": "Apple System Colors Light",
"colorScheme": "light",
"fontFamily": "",
"fontSize": 0,
"density": "comfortable",
"scale": 1,
"width": "normal",
"height": 0,
"reducedMotion": false,
"highContrast": false,
"dynamicSize": "default",
"windowKey": "key",
"frame": "window",
"window": "16x9",
"zoom": "fit",
"layout": "one"
}
},
{
"query": {
"locale": "xx",
Expand Down
92 changes: 52 additions & 40 deletions webviews/src/agent-session/acpmux/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ import {
SubagentNavContext,
SubagentOfRow,
SubagentPaneContext,
SubagentStopContext,
} from "./subagents/SubagentGroup";
import { endedEditRow, markRows, type RowMarks } from "./rowMarks";
import { ShellActionsContext, ShellRow, type ShellActions } from "./shell/ShellRow";
Expand Down Expand Up @@ -2996,6 +2997,15 @@ function AcpmuxPane() {
/// The Quick Composer panel (`"surface": "quick"` in the host's ready reply) or a tab's pane.
const [surface, setSurface] = useState<PaneSurface>("pane");
const quick = surface === "quick";
// Only Claude's agents and workflows stop on their own (acpmux `_acpmux/subagent_stop`).
const stopsSubagents = snapshot.summary?.harness === "claude";
const stopSubagent = useMemo(
() =>
stopsSubagents
? (id: string) => directClient.current?.stopSubagent(id) ?? Promise.reject(new Error())
: undefined,
[stopsSubagents],
);
/// Shows subagent `id`: in a pane of its own right of the chat (focused with `focus`), or in
/// this pane when this is a subagent's pane or the host opens none.
const showSubagent = useCallback((id: string, focus = true) => {
Expand Down Expand Up @@ -4551,47 +4561,49 @@ function AcpmuxPane() {
<SessionCwdContext.Provider value={snapshot.summary?.cwd}>
<SubagentNavContext.Provider value={setSubagentView}>
<SubagentPaneContext.Provider value={showSubagent}>
<SubagentHarnessContext.Provider
value={knownMark(snapshot.summary?.harness, snapshot.summary?.family)}
>
<ChatFindScope
rows={childRows ?? sourceRows}
shown={childRows ? undefined : transcriptRows}
openFold={openFold}
findRef={findRef}
<SubagentStopContext.Provider value={stopSubagent}>
<SubagentHarnessContext.Provider
value={knownMark(snapshot.summary?.harness, snapshot.summary?.family)}
>
{(find) => (
<VirtualTranscript
rows={transcriptRows}
working={snapshot.isWorking}
// Its stage positions Jump to latest; the Quick Composer's panel has no room for it.
jumpButton={!quick}
focusComposer={() => composerHandle.current?.focus()}
find={quick ? undefined : find}
sessionId={childRows ? `${shownSessionId}:subagent:${subagentView}` : shownSessionId}
canLoadOlder={!childRows && snapshot.canLoadOlder}
onLoadOlder={() => directClient.current?.loadOlder() ?? Promise.resolve()}
openAt={
!childRows && shownSessionId && scrollAnchor?.sessionId === shownSessionId
? scrollAnchor
: undefined
}
anchorNow={quick || childRows ? undefined : transcriptAnchor}
onAnchor={
quick || childRows || !shownSessionId
? undefined
: (anchor) => postScrollAnchor(shownSessionId, anchor)
}
expanded={expanded}
registry={registry}
githubRepository={githubRepository}
// The Quick Composer has no room for the changes view; its file rows stay plain.
onOpenDiff={quick ? undefined : openDiff}
onToggleActivity={toggleActivity}
/>
)}
</ChatFindScope>
</SubagentHarnessContext.Provider>
<ChatFindScope
rows={childRows ?? sourceRows}
shown={childRows ? undefined : transcriptRows}
openFold={openFold}
findRef={findRef}
>
{(find) => (
<VirtualTranscript
rows={transcriptRows}
working={snapshot.isWorking}
// Its stage positions Jump to latest; the Quick Composer's panel has no room for it.
jumpButton={!quick}
focusComposer={() => composerHandle.current?.focus()}
find={quick ? undefined : find}
sessionId={childRows ? `${shownSessionId}:subagent:${subagentView}` : shownSessionId}
canLoadOlder={!childRows && snapshot.canLoadOlder}
onLoadOlder={() => directClient.current?.loadOlder() ?? Promise.resolve()}
openAt={
!childRows && shownSessionId && scrollAnchor?.sessionId === shownSessionId
? scrollAnchor
: undefined
}
anchorNow={quick || childRows ? undefined : transcriptAnchor}
onAnchor={
quick || childRows || !shownSessionId
? undefined
: (anchor) => postScrollAnchor(shownSessionId, anchor)
}
expanded={expanded}
registry={registry}
githubRepository={githubRepository}
// The Quick Composer has no room for the changes view; its file rows stay plain.
onOpenDiff={quick ? undefined : openDiff}
onToggleActivity={toggleActivity}
/>
)}
</ChatFindScope>
</SubagentHarnessContext.Provider>
</SubagentStopContext.Provider>
</SubagentPaneContext.Provider>
</SubagentNavContext.Provider>
</SessionCwdContext.Provider>
Expand Down
Loading
Loading