Skip to content
Closed
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 @@ -58,6 +58,7 @@
- Slack Web API requests now use form encoding instead of JSON, preventing thread reconciliation through `conversations.replies` from failing with `invalid_arguments`.

- Managed replacement cleanup now migrates version-one receipts from earlier releases and recovers canonical exchange placeholders left by interrupted cleanup, so a stale receipt cannot permanently block the next managed session mutation with `managed_replace_cleanup_receipt_invalid`.
- ACP clients such as OpenCode can now map advertised `/skill:*` commands to canonical `skill.invoke` while binding the run to exact correlated prompt completion and cancellation ownership, and can answer deep-interview forms in headless lifecycle sessions. Lifecycle hosts initialize the shared theme before tools render and before the MCP readiness budget is calculated, preventing the pre-elicitation `theme.status` failure, and protocol form providers remain authoritative if a local `/notify on` registers an interactive source later.

## [0.12.11] - 2026-08-03

Expand Down
11 changes: 9 additions & 2 deletions packages/coding-agent/src/commands/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Args as ParsedArgs } from "../cli/args";
import { Settings } from "../config/settings";
import { applyStartupModelProfiles, createSessionManager } from "../main";
import { initializeExtensions } from "../modes/runtime-init";
import { initTheme } from "../modes/theme/theme";
import { ACP_MCP_REQUEST_TIMEOUT_MS, ACP_MCP_STARTUP_HEADROOM_MS } from "../sdk/acp/mcp";
import { Broker } from "../sdk/broker/broker";
import { readBrokerDiscovery } from "../sdk/broker/discovery";
Expand Down Expand Up @@ -387,6 +388,12 @@ export async function runSessionHost(
throw await registrationFailure(error);
}

try {
await initTheme(false);
} catch (error) {
throw await registrationFailure(error);
}

// The longer MCP startup ceiling is scoped to ACP lifecycle launches only:
// it applies when this request actually carried `mcpServers`. Ordinary
// CLI/SDK `mcpConfigPath` consumers keep the manager's short default.
Expand All @@ -395,8 +402,8 @@ export async function runSessionHost(
// Inside it, the throw would be caught, reclassified as
// `registration`/`failed`, and written a second time, losing the
// `startup`/`pending` outcome the readiness cutoff is supposed to report.
// Session-manager open and MCP config write already consumed part of the
// budget, so re-read the clock here rather than reusing the earlier check.
// Session-manager open, MCP config write, and theme initialization already
// consumed part of the budget, so re-read the clock here.
let mcpStartupTimeoutMs: number | undefined;
if (mcpConfigPath !== undefined) {
const remaining = request.semanticReadyDeadlineAt - now() - ACP_MCP_STARTUP_HEADROOM_MS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ export class ExtensionRunner {
setModelProfile: async name => (await this.#setModelProfileFn?.(name)) ?? false,
cycleThinkingLevel: () => this.#cycleThinkingLevelFn?.(),
setQueueMode: (kind, mode) => this.#setQueueModeFn?.(kind, mode) ?? false,
invokeSkill: async (name, args) => await this.#invokeSkillFn?.(name, args),
invokeSkill: async (name, args, options) => await this.#invokeSkillFn?.(name, args, options),

setPlanMode: on => this.#setPlanModeFn?.(on),
operateGoal: async (op, objective) => await this.#operateGoalFn?.(op, objective),
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/extensibility/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export interface ExtensionContext {
onPreflightAccepted?: () => void;
onPreflightAcceptCommit?: () => void | Promise<void>;
onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
preflightSignal?: AbortSignal;
},
): Promise<unknown>;
setPlanMode?(on: boolean): unknown;
Expand Down Expand Up @@ -1511,6 +1512,7 @@ export interface ExtensionContextActions {
onPreflightAccepted?: () => void;
onPreflightAcceptCommit?: () => void | Promise<void>;
onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
preflightSignal?: AbortSignal;
},
) => Promise<unknown>;
setPlanMode?: (on: boolean) => unknown;
Expand Down
58 changes: 47 additions & 11 deletions packages/coding-agent/src/modes/acp/acp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ interface PromptWaiter {
terminal?: { outcome: SdkPromptTerminalOutcome; correlation: PromptCorrelation };
/** Frames for an already-settled correlation held until acknowledgement resolves ownership. */
deferredFrames: JsonObject[];
/** Coordinates a prompt-control rejection racing an acknowledged ACP cancellation. */
cancelAttempt?: Promise<boolean>;
resolve: (response: PromptResponse) => void;
reject: (error: Error) => void;
}
Expand Down Expand Up @@ -646,6 +648,14 @@ export function acpSessionStateFromConfig(
};
}

/** Recognize an advertised ACP skill command only when it is the complete, single text prompt. */
export function acpSkillInvocation(blocks: PromptRequest["prompt"]): { name: string; args: string } | undefined {
if (blocks.length !== 1 || blocks[0]?.type !== "text") return undefined;
const match = /^\/skill:([^\s]+)(?:\s+([\s\S]*))?$/.exec(blocks[0].text.trim());
if (!match?.[1]) return undefined;
return { name: match[1], args: match[2]?.trim() ?? "" };
}

/** Convert every ACP prompt block the agent advertises without silently discarding context. */
export function acpPromptPayload(blocks: PromptRequest["prompt"]): {
text: string;
Expand Down Expand Up @@ -1121,6 +1131,7 @@ export class AcpAgent implements Agent {
if (record.activePrompt) throw new AcpSdkAdapterError("conflict", "ACP session already has an active prompt.");
if (record.authFailure) throw new AcpSdkAdapterError("authentication_failed", record.authFailure);
const payload = acpPromptPayload(params.prompt);
const skillInvocation = acpSkillInvocation(params.prompt);
let waiter!: PromptWaiter;
const response = new Promise<PromptResponse>((resolve, reject) => {
waiter = {
Expand All @@ -1136,10 +1147,12 @@ export class AcpAgent implements Agent {
record.activePrompt = waiter;
});
try {
const acknowledgement = await record.adapter.prompt({
text: payload.text,
...(payload.images.length ? { images: payload.images } : {}),
});
const acknowledgement = skillInvocation
? await record.adapter.control("skill.invoke", skillInvocation)
: await record.adapter.prompt({
text: payload.text,
...(payload.images.length ? { images: payload.images } : {}),
});
const acknowledgementCorrelation = promptAcknowledgement(acknowledgement);
if (!acknowledgementCorrelation)
throw new AcpSdkAdapterError(
Expand All @@ -1160,6 +1173,7 @@ export class AcpAgent implements Agent {
);
this.#settlePrompt(record, waiter);
} catch (error) {
if (waiter.cancelAttempt && (await waiter.cancelAttempt) && waiter.settled) return await response;
waiter.deferredFrames.length = 0;
waiter.terminal = undefined;
waiter.settled = true;
Expand All @@ -1172,13 +1186,35 @@ export class AcpAgent implements Agent {
async cancel(params: { sessionId: string }): Promise<void> {
const record = this.#sessions.get(params.sessionId);
if (!record) throw new AcpSdkAdapterError("not_found", `Unknown session, not found: ${params.sessionId}`);
const acknowledgement = await record.adapter.cancel();
const result = object(object(acknowledgement)?.result) ?? object(acknowledgement);
if (result?.aborted !== true)
throw new AcpSdkAdapterError(
"abort_unacknowledged",
"SDK did not acknowledge cancellation of the active prompt.",
);
const waiter = record.activePrompt;
const cancelAttempt = waiter ? Promise.withResolvers<boolean>() : undefined;
if (waiter && cancelAttempt) waiter.cancelAttempt = cancelAttempt.promise;
try {
const acknowledgement = await record.adapter.cancel();
const result = object(object(acknowledgement)?.result) ?? object(acknowledgement);
if (result?.aborted !== true)
throw new AcpSdkAdapterError(
"abort_unacknowledged",
"SDK did not acknowledge cancellation of the active prompt.",
);
if (
result.disposition === "preflight_cancelled" &&
waiter &&
record.activePrompt === waiter &&
!waiter.acknowledged &&
!waiter.settled
) {
record.activePrompt = undefined;
waiter.settled = true;
waiter.deferredFrames.length = 0;
waiter.terminal = undefined;
waiter.resolve({ stopReason: "cancelled" });
}
cancelAttempt?.resolve(true);
} catch (error) {
cancelAttempt?.resolve(false);
throw error;
}
}

async extMethod(method: string, params: JsonObject): Promise<JsonObject> {
Expand Down
Loading