Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Fixed daemon-aborted extension dialogs remaining live in the interactive client and sending a stale response after another client had already decided the request.
- Fixed fullscreen wheel scrolling in Ghostty while retaining application link clicks; set `terminal.fullscreenMouse` to `false` to use native Cmd-click instead.
- Changed the agents view to sort idle and inactive sessions by last message time, newest first, while keeping running agents in stable creation order.
- Fixed `openai-codex` models being invisible to `rlm` subagents and `find_models` because model discovery reported Prime Agent's own version as the Codex client version ([#1375](https://github.com/PrimeIntellect-ai/prime-agent/pull/1375) by [@bilelrais](https://github.com/bilelrais)).
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/agent-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ An initial or replacement snapshot combines:
- active RLM child snapshots; and
- an in-progress assistant message when one exists.

Connection events cover session events, replacement and resynchronization snapshots, extension UI requests, connection status, and terminal closure. The adapter updates its cache before notifying the UI.
Connection events cover session events, replacement and resynchronization snapshots, extension UI requests and exact-request cancellations, connection status, and terminal closure. The adapter updates its cache before notifying the UI.

Some connection types still reuse internal `AgentMessage`, `AgentEvent`, and model types. Those are local TypeScript contracts, not promises of a stable public network schema.

Expand Down Expand Up @@ -116,7 +116,7 @@ When switching to a session already owned by another resident worker, a non-owne

## Extension UI Boundary

Daemon-owned extensions can request serializable UI operations such as select, confirm, input, editor, notification, status, widget, title, and editor-text updates. The client validates the payload and returns a serializable response.
Daemon-owned extensions can request serializable UI operations such as select, confirm, input, editor, notification, status, widget, title, and editor-text updates. The client validates the payload and returns a serializable response. If the daemon aborts or times out a pending dialog, it sends a capability-gated cancellation for that exact request ID; the interactive client retires only that request without sending a response.

Executable callbacks are deliberately excluded:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ export class DaemonAgentConnection implements AgentConnection {
capabilities: [
"attach_snapshot",
"event_sequence",
...(supportsExtensionUi ? (["extension_ui"] as const) : []),
...(supportsExtensionUi ? (["extension_ui", "extension_ui_cancellation"] as const) : []),
"slim_attach",
"chunked_snapshot",
...(this.options.ownedSession ? (["client_owned_sessions"] as const) : []),
Expand Down Expand Up @@ -1156,7 +1156,7 @@ export class DaemonAgentConnection implements AgentConnection {
capabilities: [
"attach_snapshot",
"event_sequence",
...(supportsExtensionUi ? (["extension_ui"] as const) : []),
...(supportsExtensionUi ? (["extension_ui", "extension_ui_cancellation"] as const) : []),
"slim_attach",
"chunked_snapshot",
...(this.options.ownedSession ? (["client_owned_sessions"] as const) : []),
Expand Down Expand Up @@ -1555,6 +1555,10 @@ export class DaemonAgentConnection implements AgentConnection {
});
return;
}
if (message.type === "extension_ui_cancelled") {
await this.emit({ type: "extension_ui_cancelled", requestId: message.id });
return;
}
if (message.type === "extension_error") {
await this.emit({
type: "extension_error",
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/modes/agent-connection/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,7 @@ export type AgentConnectionEvent =
| { type: "session_resynced"; snapshot: AgentConnectionSnapshot }
| { type: "session_status"; recap?: string }
| { type: "extension_ui_request"; request: AgentConnectionExtensionUiRequest }
| { type: "extension_ui_cancelled"; requestId: string }
| { type: "extension_error"; extensionPath: string; event: string; error: string }
| { type: "connection_status"; status: "reconnecting" | "connected"; error?: string }
| { type: "heartbeats_changed" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,13 @@ function createExtensionUIContext(
clearTimeout(timeoutId);
}
opts?.signal?.removeEventListener("abort", onAbort);
state.extensionUiRequests.delete(requestId);
if (state.extensionUiRequests.delete(requestId)) {
broadcast(state, {
type: "extension_ui_cancelled",
activeSessionId: state.activeSessionId,
id: requestId,
});
}
};
const finish = (value: T) => {
cleanup();
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6888,6 +6888,7 @@ type SequencedDaemonOutbound = Extract<
| "session_resynced"
| "session_closed"
| "extension_ui_request"
| "extension_ui_cancelled"
| "extension_error";
}
>;
Expand All @@ -6900,11 +6901,15 @@ function isSequencedSessionOutbound(message: DaemonOutbound): message is Sequenc
message.type === "session_resynced" ||
message.type === "session_closed" ||
message.type === "extension_ui_request" ||
message.type === "extension_ui_cancelled" ||
message.type === "extension_error"
);
}

export function shouldSendDaemonOutboundToClient(client: DaemonSocketClient, message: DaemonOutbound): boolean {
if (message.type === "extension_ui_cancelled") {
return daemonClientCapabilitiesForSession(client, message.activeSessionId).has("extension_ui_cancellation");
}
return (
message.type !== "extension_ui_request" ||
!isDaemonDialogExtensionUiRequest(message.method) ||
Expand Down
13 changes: 11 additions & 2 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7;
// Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach.
// Revision 15 adds the mutate_queued_message command and queue_message_mutation capability.
// Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready".
export const DAEMON_SCHEMA_REVISION = 16;
export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49";
// Revision 17 adds capability-gated exact-request extension UI cancellation.
export const DAEMON_SCHEMA_REVISION = 17;
export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-28439aeb8512";

export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME;
export type DaemonProtocolVersion = number;
Expand All @@ -77,6 +78,7 @@ export type DaemonClientCapability =
| "attach_snapshot"
| "event_sequence"
| "extension_ui"
| "extension_ui_cancellation"
| "slim_attach"
| "chunked_snapshot"
| "client_owned_sessions";
Expand Down Expand Up @@ -123,6 +125,7 @@ export const DAEMON_SUPPORTED_CLIENT_CAPABILITIES: readonly DaemonClientCapabili
"attach_snapshot",
"event_sequence",
"extension_ui",
"extension_ui_cancellation",
"slim_attach",
"chunked_snapshot",
"client_owned_sessions",
Expand Down Expand Up @@ -938,6 +941,7 @@ export type DaemonOutbound =
payload: Record<string, unknown>;
meta?: DaemonEventMeta;
}
| { type: "extension_ui_cancelled"; activeSessionId: string; id: string; meta?: DaemonEventMeta }
| {
type: "extension_error";
activeSessionId: string;
Expand Down Expand Up @@ -967,6 +971,11 @@ export const DAEMON_OUTBOUND_COMPATIBILITY = {
session_detached: LEGACY_DAEMON_COMMAND,
session_closed: LEGACY_DAEMON_COMMAND,
extension_ui_request: LEGACY_DAEMON_COMMAND,
extension_ui_cancelled: {
minProtocol: 7,
minSchemaRevision: 17,
capability: "extension_ui_cancellation",
},
extension_error: LEGACY_DAEMON_COMMAND,
} as const satisfies Record<DaemonOutbound["type"], DaemonCommandCompatibility>;

Expand Down
22 changes: 17 additions & 5 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2422,15 +2422,24 @@ export class DaemonSupervisor {
if (!worker.client) {
throw new Error("Session worker is not connected");
}
const supportsExtensionUi = [...this.clients].some(
(client) => client.attachedActiveSessionIds.has(activeSessionId) && client.supportsExtensionUi,
const attachedClients = [...this.clients].filter((client) =>
client.attachedActiveSessionIds.has(activeSessionId),
);
const supportsExtensionUi = attachedClients.some((client) => client.supportsExtensionUi);
const supportsExtensionUiCancellation = attachedClients.some(
(client) => client.supportsExtensionUi && client.capabilities.has("extension_ui_cancellation"),
);
const response = await worker.client.requestWorker({
type: "worker_subscribe",
activeSessionId,
capabilities: supportsExtensionUi
? ["attach_snapshot", "event_sequence", "extension_ui", "slim_attach", "chunked_snapshot"]
: ["attach_snapshot", "event_sequence", "slim_attach", "chunked_snapshot"],
capabilities: [
"attach_snapshot",
"event_sequence",
...(supportsExtensionUi ? (["extension_ui"] as const) : []),
...(supportsExtensionUiCancellation ? (["extension_ui_cancellation"] as const) : []),
"slim_attach",
"chunked_snapshot",
],
supportsExtensionUi,
});
if (!response.success) {
Expand Down Expand Up @@ -4257,6 +4266,9 @@ export class DaemonSupervisor {
if (outboundType === "extension_ui_request" && !client.supportsExtensionUi) {
continue;
}
if (outboundType === "extension_ui_cancelled" && !client.capabilities.has("extension_ui_cancellation")) {
continue;
}
if (client.snapshotActiveSessionIds?.has(activeSessionId)) {
this.queueCatchup(client, activeSessionId, outboundType === "session_replaced" ? "replacement" : "resync");
continue;
Expand Down
47 changes: 43 additions & 4 deletions packages/coding-agent/src/modes/interactive/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3906,18 +3906,35 @@ export class InteractiveMode {
/**
* Show a multi-line editor for extensions (with Ctrl+G support).
*/
private showExtensionEditor(title: string, prefill?: string): Promise<string | undefined> {
private showExtensionEditor(
title: string,
prefill?: string,
opts?: ExtensionUIDialogOptions,
): Promise<string | undefined> {
return new Promise((resolve) => {
if (opts?.signal?.aborted) {
resolve(undefined);
return;
}

const onAbort = () => {
this.hideExtensionEditor();
resolve(undefined);
};
opts?.signal?.addEventListener("abort", onAbort, { once: true });

this.extensionEditor = new ExtensionEditorComponent(
this.ui,
this.keybindings,
title,
prefill,
(value) => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionEditor();
resolve(value);
},
() => {
opts?.signal?.removeEventListener("abort", onAbort);
this.hideExtensionEditor();
resolve(undefined);
},
Expand Down Expand Up @@ -5135,6 +5152,8 @@ export class InteractiveMode {
this.handleSideQuestionEvent(event.event);
} else if (event.type === "extension_ui_request") {
await this.handleConnectionExtensionUiRequest(event.request);
} else if (event.type === "extension_ui_cancelled") {
this.cancelConnectionExtensionUiRequest(event.requestId);
} else if (event.type === "connection_status") {
this.showStatus(
event.status === "connected" ? "Daemon reconnected" : "Daemon connection lost; reconnecting…",
Expand All @@ -5160,14 +5179,21 @@ export class InteractiveMode {

try {
if (expectsResponse) {
const dialogController = new AbortController();
let cancelLocal: (response: AgentConnectionExtensionUiResponse) => void = () => {};
const cancelled = new Promise<AgentConnectionExtensionUiResponse>((resolve) => {
cancelLocal = resolve;
});
this.activeConnectionExtensionUiRequests.set(request.id, {
cancelLocal: () => cancelLocal({ cancelled: true }),
cancelLocal: () => {
dialogController.abort();
cancelLocal({ cancelled: true });
},
});
response = await Promise.race([this.resolveConnectionExtensionUiRequest(request), cancelled]);
response = await Promise.race([
this.resolveConnectionExtensionUiRequest(request, dialogController.signal),
cancelled,
]);
} else {
response = await this.resolveConnectionExtensionUiRequest(request);
}
Expand Down Expand Up @@ -5201,6 +5227,15 @@ export class InteractiveMode {
);
}

private cancelConnectionExtensionUiRequest(requestId: string): void {
const request = this.activeConnectionExtensionUiRequests.get(requestId);
if (!request) {
return;
}
this.activeConnectionExtensionUiRequests.delete(requestId);
request.cancelLocal();
}

private cancelActiveConnectionExtensionUiRequests(): void {
const requestIds = [...this.activeConnectionExtensionUiRequests.keys()];
for (const requestId of requestIds) {
Expand All @@ -5218,6 +5253,7 @@ export class InteractiveMode {

private async resolveConnectionExtensionUiRequest(
request: AgentConnectionExtensionUiRequest,
signal?: AbortSignal,
): Promise<AgentConnectionExtensionUiResponse | undefined> {
const { payload } = request;
switch (request.method) {
Expand All @@ -5229,6 +5265,7 @@ export class InteractiveMode {
}
const value = await this.showExtensionSelector(title, options, {
timeout: getPayloadNumber(payload, "timeout"),
signal,
});
return value === undefined ? { cancelled: true } : { value };
}
Expand All @@ -5240,6 +5277,7 @@ export class InteractiveMode {
}
const confirmed = await this.showExtensionConfirm(title, message, {
timeout: getPayloadNumber(payload, "timeout"),
signal,
});
return { confirmed };
}
Expand All @@ -5250,6 +5288,7 @@ export class InteractiveMode {
}
const value = await this.showExtensionInput(title, getPayloadString(payload, "placeholder"), {
timeout: getPayloadNumber(payload, "timeout"),
signal,
});
return value === undefined ? { cancelled: true } : { value };
}
Expand All @@ -5258,7 +5297,7 @@ export class InteractiveMode {
if (!title) {
return { cancelled: true };
}
const value = await this.showExtensionEditor(title, getPayloadString(payload, "prefill"));
const value = await this.showExtensionEditor(title, getPayloadString(payload, "prefill"), { signal });
return value === undefined ? { cancelled: true } : { value };
}
case "notify": {
Expand Down
Loading