Merge origin/main into share-chat-ui-components

This commit is contained in:
Nathan Flurry 2026-03-10 22:07:05 -07:00
commit b609f1ab2b
306 changed files with 44551 additions and 1001 deletions

View file

@ -1,6 +1,6 @@
{
"name": "acp-http-client",
"version": "0.3.0",
"version": "0.3.1",
"description": "Protocol-faithful ACP JSON-RPC over streamable HTTP client.",
"license": "Apache-2.0",
"repository": {

View file

@ -378,31 +378,39 @@ class StreamableHttpAcpTransport {
});
const url = this.buildUrl(this.bootstrapQueryIfNeeded());
const response = await this.fetcher(url, {
method: "POST",
headers,
body: JSON.stringify(message),
});
this.postedOnce = true;
if (!response.ok) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
this.ensureSseLoop();
void this.postMessage(url, headers, message);
}
if (response.status === 200) {
const text = await response.text();
if (text.trim()) {
const envelope = JSON.parse(text) as AnyMessage;
this.pushInbound(envelope);
private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise<void> {
try {
const response = await this.fetcher(url, {
method: "POST",
headers,
body: JSON.stringify(message),
});
if (!response.ok) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
} else {
if (response.status === 200) {
const text = await response.text();
if (text.trim()) {
const envelope = JSON.parse(text) as AnyMessage;
this.pushInbound(envelope);
}
return;
}
// Drain response body so the underlying connection is released back to
// the pool. Without this, Node.js undici keeps the socket occupied and
// the pool. Without this, Node.js undici keeps the socket occupied and
// may stall subsequent requests to the same origin.
await response.text().catch(() => {});
} catch (error) {
console.error("ACP write error:", error);
this.failReadable(error);
}
}

View file

@ -140,4 +140,54 @@ describe("AcpHttpClient integration", () => {
await client.disconnect();
});
it("answers session/request_permission while session/prompt is still in flight", async () => {
const permissionRequests: Array<{ sessionId: string; title?: string | null }> = [];
const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`;
const client = new AcpHttpClient({
baseUrl,
token,
transport: {
path: `/v1/acp/${encodeURIComponent(serverId)}`,
bootstrapQuery: { agent: "mock" },
},
client: {
requestPermission: async (request) => {
permissionRequests.push({
sessionId: request.sessionId,
title: request.toolCall.title,
});
return {
outcome: {
outcome: "selected",
optionId: "reject-once",
},
};
},
},
});
await client.initialize();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
const prompt = await client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "please trigger permission" }],
});
expect(prompt.stopReason).toBe("end_turn");
expect(permissionRequests).toEqual([
{
sessionId: session.sessionId,
title: "Write mock.txt",
},
]);
await client.disconnect();
});
});

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-shared",
"version": "0.3.0",
"version": "0.3.1",
"description": "Shared helpers for sandbox-agent CLI and SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli",
"version": "0.3.0",
"version": "0.3.1",
"description": "CLI for sandbox-agent - run AI coding agents in sandboxes",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-darwin-arm64",
"version": "0.3.0",
"version": "0.3.1",
"description": "sandbox-agent CLI binary for macOS ARM64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-darwin-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "sandbox-agent CLI binary for macOS x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-linux-arm64",
"version": "0.3.0",
"version": "0.3.1",
"description": "sandbox-agent CLI binary for Linux arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-linux-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "sandbox-agent CLI binary for Linux x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/cli-win32-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "sandbox-agent CLI binary for Windows x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode",
"version": "0.3.0",
"version": "0.3.1",
"description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-darwin-arm64",
"version": "0.3.0",
"version": "0.3.1",
"description": "gigacode CLI binary for macOS arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-darwin-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "gigacode CLI binary for macOS x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-linux-arm64",
"version": "0.3.0",
"version": "0.3.1",
"description": "gigacode CLI binary for Linux arm64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-linux-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "gigacode CLI binary for Linux x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/gigacode-win32-x64",
"version": "0.3.0",
"version": "0.3.1",
"description": "gigacode CLI binary for Windows x64",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/persist-indexeddb",
"version": "0.3.0",
"version": "0.3.1",
"description": "IndexedDB persistence driver for the Sandbox Agent TypeScript SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/persist-postgres",
"version": "0.3.0",
"version": "0.3.1",
"description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/persist-rivet",
"version": "0.3.0",
"version": "0.3.1",
"description": "Rivet Actor persistence driver for the Sandbox Agent TypeScript SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/persist-sqlite",
"version": "0.3.0",
"version": "0.3.1",
"description": "SQLite persistence driver for the Sandbox Agent TypeScript SDK",
"license": "Apache-2.0",
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@sandbox-agent/react",
"version": "0.3.0",
"version": "0.3.1",
"description": "React components for Sandbox Agent frontend integrations",
"license": "Apache-2.0",
"repository": {

View file

@ -3,10 +3,18 @@
import type { ReactNode, RefObject } from "react";
import { useMemo, useState } from "react";
export type PermissionReply = "once" | "always" | "reject";
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TranscriptEntry = {
id: string;
eventId?: string;
kind: "message" | "tool" | "meta" | "reasoning";
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
time: string;
role?: "user" | "assistant";
text?: string;
@ -16,6 +24,14 @@ export type TranscriptEntry = {
toolStatus?: string;
reasoning?: { text: string; visibility?: string };
meta?: { title: string; detail?: string; severity?: "info" | "error" };
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
};
export interface AgentTranscriptClassNames {
@ -50,6 +66,14 @@ export interface AgentTranscriptClassNames {
toolSectionTitle: string;
toolCode: string;
toolCodeMuted: string;
permissionPrompt: string;
permissionHeader: string;
permissionIcon: string;
permissionTitle: string;
permissionDescription: string;
permissionActions: string;
permissionButton: string;
permissionAutoResolved: string;
thinkingRow: string;
thinkingAvatar: string;
thinkingAvatarImage: string;
@ -59,6 +83,16 @@ export interface AgentTranscriptClassNames {
endAnchor: string;
}
export interface PermissionOptionRenderContext {
entry: TranscriptEntry;
option: PermissionOption;
label: string;
reply: PermissionReply;
selected: boolean;
dimmed: boolean;
resolved: boolean;
}
export interface AgentTranscriptProps {
entries: TranscriptEntry[];
className?: string;
@ -69,6 +103,7 @@ export interface AgentTranscriptProps {
isThinking?: boolean;
agentId?: string;
onEventClick?: (eventId: string) => void;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
isDividerEntry?: (entry: TranscriptEntry) => boolean;
canOpenEvent?: (entry: TranscriptEntry) => boolean;
getToolGroupSummary?: (entries: TranscriptEntry[]) => string;
@ -79,12 +114,15 @@ export interface AgentTranscriptProps {
renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron?: (expanded: boolean) => ReactNode;
renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode;
renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode;
}
type GroupedEntries =
| { type: "message"; entries: TranscriptEntry[] }
| { type: "tool-group"; entries: TranscriptEntry[] }
| { type: "divider"; entries: TranscriptEntry[] };
| { type: "divider"; entries: TranscriptEntry[] }
| { type: "permission"; entries: TranscriptEntry[] };
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
root: "sa-agent-transcript",
@ -118,6 +156,14 @@ const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
toolSectionTitle: "sa-agent-transcript-tool-section-title",
toolCode: "sa-agent-transcript-tool-code",
toolCodeMuted: "sa-agent-transcript-tool-code-muted",
permissionPrompt: "sa-agent-transcript-permission-prompt",
permissionHeader: "sa-agent-transcript-permission-header",
permissionIcon: "sa-agent-transcript-permission-icon",
permissionTitle: "sa-agent-transcript-permission-title",
permissionDescription: "sa-agent-transcript-permission-description",
permissionActions: "sa-agent-transcript-permission-actions",
permissionButton: "sa-agent-transcript-permission-button",
permissionAutoResolved: "sa-agent-transcript-permission-auto-resolved",
thinkingRow: "sa-agent-transcript-thinking-row",
thinkingAvatar: "sa-agent-transcript-thinking-avatar",
thinkingAvatarImage: "sa-agent-transcript-thinking-avatar-image",
@ -129,6 +175,8 @@ const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
const DEFAULT_DIVIDER_TITLES = new Set(["Session Started", "Turn Started", "Turn Ended"]);
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: AgentTranscriptClassNames,
overrides?: Partial<AgentTranscriptClassNames>,
@ -164,6 +212,14 @@ const mergeClassNames = (
toolSectionTitle: cx(defaults.toolSectionTitle, overrides?.toolSectionTitle),
toolCode: cx(defaults.toolCode, overrides?.toolCode),
toolCodeMuted: cx(defaults.toolCodeMuted, overrides?.toolCodeMuted),
permissionPrompt: cx(defaults.permissionPrompt, overrides?.permissionPrompt),
permissionHeader: cx(defaults.permissionHeader, overrides?.permissionHeader),
permissionIcon: cx(defaults.permissionIcon, overrides?.permissionIcon),
permissionTitle: cx(defaults.permissionTitle, overrides?.permissionTitle),
permissionDescription: cx(defaults.permissionDescription, overrides?.permissionDescription),
permissionActions: cx(defaults.permissionActions, overrides?.permissionActions),
permissionButton: cx(defaults.permissionButton, overrides?.permissionButton),
permissionAutoResolved: cx(defaults.permissionAutoResolved, overrides?.permissionAutoResolved),
thinkingRow: cx(defaults.thinkingRow, overrides?.thinkingRow),
thinkingAvatar: cx(defaults.thinkingAvatar, overrides?.thinkingAvatar),
thinkingAvatarImage: cx(defaults.thinkingAvatarImage, overrides?.thinkingAvatarImage),
@ -173,12 +229,11 @@ const mergeClassNames = (
endAnchor: cx(defaults.endAnchor, overrides?.endAnchor),
});
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const getMessageVariant = (entry: TranscriptEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.kind === "permission") return "system";
if (entry.role === "user") return "user";
return "assistant";
};
@ -210,10 +265,31 @@ const getDefaultToolGroupSummary = (entries: TranscriptEntry[]) => {
return `${count} Event${count === 1 ? "" : "s"}`;
};
const getPermissionReplyForOption = (kind: string): PermissionReply => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const getPermissionOptionLabel = (option: PermissionOption) => {
if (option.name) return option.name;
if (option.kind === "allow_once") return "Allow Once";
if (option.kind === "allow_always") return "Always Allow";
if (option.kind === "reject_once") return "Reject";
if (option.kind === "reject_always") return "Reject Always";
return option.kind;
};
const getPermissionOptionTone = (kind: string) => (kind.startsWith("allow") ? "allow" : "reject");
const defaultRenderMessageText = (entry: TranscriptEntry) => entry.text;
const defaultRenderPendingIndicator = () => "...";
const defaultRenderChevron = (expanded: boolean) => (expanded ? "▾" : "▸");
const defaultRenderEventLinkContent = () => "Open";
const defaultRenderPermissionIcon = () => "Permission";
const defaultRenderPermissionOptionContent = ({
label,
}: PermissionOptionRenderContext) => label;
const defaultIsDividerEntry = (entry: TranscriptEntry) =>
entry.kind === "meta" && DEFAULT_DIVIDER_TITLES.has(entry.meta?.title ?? "");
@ -241,6 +317,12 @@ const buildGroupedEntries = (
continue;
}
if (entry.kind === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", entries: [entry] });
continue;
}
if (entry.kind === "tool" || entry.kind === "reasoning" || entry.kind === "meta") {
currentToolGroup.push(entry);
continue;
@ -486,6 +568,89 @@ const ToolGroup = ({
);
};
const PermissionPrompt = ({
entry,
classNames,
onPermissionReply,
renderPermissionIcon,
renderPermissionOptionContent,
}: {
entry: TranscriptEntry;
classNames: AgentTranscriptClassNames;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
renderPermissionIcon: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent: (context: PermissionOptionRenderContext) => ReactNode;
}) => {
const permission = entry.permission;
if (!permission) {
return null;
}
const resolved = Boolean(permission.resolved);
const selectedOptionId = permission.selectedOptionId;
const canReply = Boolean(onPermissionReply) && !resolved;
return (
<div
className={cx(classNames.permissionPrompt, resolved && "resolved")}
data-slot="permission-prompt"
data-resolved={resolved ? "true" : undefined}
>
<div className={classNames.permissionHeader} data-slot="permission-header">
<span className={classNames.permissionIcon} data-slot="permission-icon">
{renderPermissionIcon(entry)}
</span>
<span className={classNames.permissionTitle} data-slot="permission-title">
{permission.title}
</span>
</div>
{permission.description ? (
<div className={classNames.permissionDescription} data-slot="permission-description">
{permission.description}
</div>
) : null}
<div className={classNames.permissionActions} data-slot="permission-actions">
{permission.options.map((option) => {
const reply = getPermissionReplyForOption(option.kind);
const label = getPermissionOptionLabel(option);
const selected = resolved && selectedOptionId === option.optionId;
const dimmed = resolved && !selected && selectedOptionId != null;
const tone = getPermissionOptionTone(option.kind);
return (
<button
key={option.optionId}
type="button"
className={cx(classNames.permissionButton, tone, selected && "selected", dimmed && "dimmed")}
data-slot="permission-button"
data-tone={tone}
data-selected={selected ? "true" : undefined}
data-dimmed={dimmed ? "true" : undefined}
disabled={!canReply}
onClick={() => onPermissionReply?.(permission.permissionId, reply)}
>
{renderPermissionOptionContent({
entry,
option,
label,
reply,
selected,
dimmed,
resolved,
})}
</button>
);
})}
{resolved && !selectedOptionId ? (
<span className={classNames.permissionAutoResolved} data-slot="permission-auto-resolved">
Auto-resolved
</span>
) : null}
</div>
</div>
);
};
export const AgentTranscript = ({
entries,
className,
@ -496,6 +661,7 @@ export const AgentTranscript = ({
isThinking,
agentId,
onEventClick,
onPermissionReply,
isDividerEntry = defaultIsDividerEntry,
canOpenEvent = defaultCanOpenEvent,
getToolGroupSummary = getDefaultToolGroupSummary,
@ -506,6 +672,8 @@ export const AgentTranscript = ({
renderToolGroupIcon = () => null,
renderChevron = defaultRenderChevron,
renderEventLinkContent = defaultRenderEventLinkContent,
renderPermissionIcon = defaultRenderPermissionIcon,
renderPermissionOptionContent = defaultRenderPermissionOptionContent,
}: AgentTranscriptProps) => {
const resolvedClassNames = useMemo(
() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides),
@ -551,6 +719,20 @@ export const AgentTranscript = ({
);
}
if (group.type === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
classNames={resolvedClassNames}
onPermissionReply={onPermissionReply}
renderPermissionIcon={renderPermissionIcon}
renderPermissionOptionContent={renderPermissionOptionContent}
/>
);
}
const entry = group.entries[0];
const messageVariant = getMessageVariant(entry);

View file

@ -11,6 +11,9 @@ export type {
export type {
AgentTranscriptClassNames,
AgentTranscriptProps,
PermissionOption,
PermissionOptionRenderContext,
PermissionReply,
TranscriptEntry,
} from "./AgentTranscript.tsx";

View file

@ -1,6 +1,6 @@
{
"name": "sandbox-agent",
"version": "0.3.0",
"version": "0.3.1",
"description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
"license": "Apache-2.0",
"repository": {

View file

@ -8,8 +8,12 @@ import {
type CancelNotification,
type NewSessionRequest,
type NewSessionResponse,
type PermissionOption,
type PermissionOptionKind,
type PromptRequest,
type PromptResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionConfigOption,
type SessionNotification,
type SessionModeState,
@ -142,9 +146,28 @@ export interface SessionSendOptions {
}
export type SessionEventListener = (event: SessionEvent) => void;
export type PermissionReply = "once" | "always" | "reject";
export type PermissionRequestListener = (request: SessionPermissionRequest) => void;
export type ProcessLogListener = (entry: ProcessLogEntry) => void;
export type ProcessLogFollowQuery = Omit<ProcessLogsQuery, "follow">;
export interface SessionPermissionRequestOption {
optionId: string;
name: string;
kind: PermissionOptionKind;
}
export interface SessionPermissionRequest {
id: string;
createdAt: number;
sessionId: string;
agentSessionId: string;
availableReplies: PermissionReply[];
options: SessionPermissionRequestOption[];
toolCall: RequestPermissionRequest["toolCall"];
rawRequest: RequestPermissionRequest;
}
export interface AgentQueryOptions {
config?: boolean;
noCache?: boolean;
@ -238,6 +261,22 @@ export class UnsupportedSessionConfigOptionError extends Error {
}
}
export class UnsupportedPermissionReplyError extends Error {
readonly permissionId: string;
readonly requestedReply: PermissionReply;
readonly availableReplies: PermissionReply[];
constructor(permissionId: string, requestedReply: PermissionReply, availableReplies: PermissionReply[]) {
super(
`Permission '${permissionId}' does not support reply '${requestedReply}'. Available replies: ${availableReplies.join(", ") || "(none)"}`,
);
this.name = "UnsupportedPermissionReplyError";
this.permissionId = permissionId;
this.requestedReply = requestedReply;
this.availableReplies = availableReplies;
}
}
export class Session {
private record: SessionRecord;
private readonly sandbox: SandboxAgent;
@ -280,14 +319,14 @@ export class Session {
return this;
}
async send(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
const updated = await this.sandbox.sendSessionMethod(this.id, method, params, options);
async rawSend(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
const updated = await this.sandbox.rawSendSessionMethod(this.id, method, params, options);
this.apply(updated.session.toRecord());
return updated.response;
}
async prompt(prompt: PromptRequest["prompt"]): Promise<PromptResponse> {
const response = await this.send("session/prompt", { prompt });
const response = await this.rawSend("session/prompt", { prompt });
return response as PromptResponse;
}
@ -327,6 +366,18 @@ export class Session {
return this.sandbox.onSessionEvent(this.id, listener);
}
onPermissionRequest(listener: PermissionRequestListener): () => void {
return this.sandbox.onPermissionRequest(this.id, listener);
}
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
await this.sandbox.respondPermission(permissionId, reply);
}
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
await this.sandbox.rawRespondPermission(permissionId, response);
}
toRecord(): SessionRecord {
return { ...this.record };
}
@ -355,6 +406,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void;
private readonly onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
private constructor(
agent: string,
@ -366,11 +423,18 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void,
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>,
) {
this.agent = agent;
this.connectionId = connectionId;
this.acp = acp;
this.onObservedEnvelope = onObservedEnvelope;
this.onPermissionRequest = onPermissionRequest;
}
static async create(options: {
@ -386,6 +450,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection,
localSessionId: string | null,
) => void;
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
}): Promise<LiveAcpConnection> {
const connectionId = randomId();
@ -400,6 +470,12 @@ export class LiveAcpConnection {
bootstrapQuery: { agent: options.agent },
},
client: {
requestPermission: async (request: RequestPermissionRequest) => {
if (!live) {
return cancelledPermissionResponse();
}
return live.handlePermissionRequest(request);
},
sessionUpdate: async (_notification: SessionNotification) => {
// Session updates are observed via envelope persistence.
},
@ -416,7 +492,13 @@ export class LiveAcpConnection {
},
});
live = new LiveAcpConnection(options.agent, connectionId, acp, options.onObservedEnvelope);
live = new LiveAcpConnection(
options.agent,
connectionId,
acp,
options.onObservedEnvelope,
options.onPermissionRequest,
);
const initResult = await acp.initialize({
protocolVersion: PROTOCOL_VERSION,
@ -550,6 +632,23 @@ export class LiveAcpConnection {
this.lastAdapterExitAt = Date.now();
}
private async handlePermissionRequest(
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const agentSessionId = request.sessionId;
const localSessionId = this.localByAgentSessionId.get(agentSessionId);
if (!localSessionId) {
return cancelledPermissionResponse();
}
return this.onPermissionRequest(
this,
localSessionId,
agentSessionId,
clonePermissionRequest(request),
);
}
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
const id = envelopeId(envelope);
const method = envelopeMethod(envelope);
@ -782,6 +881,8 @@ export class SandboxAgent {
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
private readonly sessionHandles = new Map<string, Session>();
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
private readonly permissionListeners = new Map<string, Set<PermissionRequestListener>>();
private readonly pendingPermissionRequests = new Map<string, PendingPermissionRequestState>();
private readonly nextSessionEventIndexBySession = new Map<string, number>();
private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>();
@ -840,6 +941,11 @@ export class SandboxAgent {
this.disposed = true;
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed."));
for (const [permissionId, pending] of this.pendingPermissionRequests) {
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(cancelledPermissionResponse());
}
const connections = [...this.liveConnections.values()];
this.liveConnections.clear();
const pending = [...this.pendingLiveConnections.values()];
@ -984,6 +1090,8 @@ export class SandboxAgent {
}
async destroySession(id: string): Promise<Session> {
this.cancelPendingPermissionsForSession(id);
try {
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
} catch {
@ -1100,7 +1208,26 @@ export class SandboxAgent {
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
const record = await this.requireSessionRecord(sessionId);
return cloneModes(record.modes);
if (record.modes && record.modes.availableModes.length > 0) {
return cloneModes(record.modes);
}
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
if (hydrated.modes && hydrated.modes.availableModes.length > 0) {
return cloneModes(hydrated.modes);
}
const derived = deriveModesFromConfigOptions(hydrated.configOptions);
if (!derived) {
return cloneModes(hydrated.modes);
}
const updated: SessionRecord = {
...hydrated,
modes: derived,
};
await this.persist.updateSession(updated);
return cloneModes(derived);
}
private async setSessionCategoryValue(
@ -1155,7 +1282,7 @@ export class SandboxAgent {
return updated;
}
async sendSessionMethod(
async rawSendSessionMethod(
sessionId: string,
method: string,
params: Record<string, unknown>,
@ -1290,6 +1417,47 @@ export class SandboxAgent {
};
}
onPermissionRequest(sessionId: string, listener: PermissionRequestListener): () => void {
const listeners = this.permissionListeners.get(sessionId) ?? new Set<PermissionRequestListener>();
listeners.add(listener);
this.permissionListeners.set(sessionId, listeners);
return () => {
const set = this.permissionListeners.get(sessionId);
if (!set) {
return;
}
set.delete(listener);
if (set.size === 0) {
this.permissionListeners.delete(sessionId);
}
};
}
async respondPermission(permissionId: string, reply: PermissionReply): Promise<void> {
const pending = this.pendingPermissionRequests.get(permissionId);
if (!pending) {
throw new Error(`permission '${permissionId}' not found`);
}
let response: RequestPermissionResponse;
try {
response = permissionReplyToResponse(permissionId, pending.request, reply);
} catch (error) {
pending.reject(error instanceof Error ? error : new Error(String(error)));
this.pendingPermissionRequests.delete(permissionId);
throw error;
}
this.resolvePendingPermission(permissionId, response);
}
async rawRespondPermission(permissionId: string, response: RequestPermissionResponse): Promise<void> {
if (!this.pendingPermissionRequests.has(permissionId)) {
throw new Error(`permission '${permissionId}' not found`);
}
this.resolvePendingPermission(permissionId, clonePermissionResponse(response));
}
async getHealth(): Promise<HealthResponse> {
return this.requestHealth();
}
@ -1301,9 +1469,22 @@ export class SandboxAgent {
}
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
query: toAgentQuery(options),
});
try {
return await this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, {
query: toAgentQuery(options),
});
} catch (error) {
if (!(error instanceof SandboxAgentError) || error.status !== 404) {
throw error;
}
const listed = await this.listAgents(options);
const match = listed.agents.find((entry) => entry.id === agent);
if (match) {
return match;
}
throw error;
}
}
async installAgent(agent: string, request: AgentInstallRequest = {}): Promise<AgentInstallResponse> {
@ -1551,6 +1732,8 @@ export class SandboxAgent {
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
},
onPermissionRequest: async (connection, localSessionId, agentSessionId, request) =>
this.enqueuePermissionRequest(connection, localSessionId, agentSessionId, request),
});
const raced = this.liveConnections.get(agent);
@ -1753,6 +1936,69 @@ export class SandboxAgent {
return record;
}
private async enqueuePermissionRequest(
_connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
): Promise<RequestPermissionResponse> {
const listeners = this.permissionListeners.get(localSessionId);
if (!listeners || listeners.size === 0) {
return cancelledPermissionResponse();
}
const pendingId = randomId();
const permissionRequest: SessionPermissionRequest = {
id: pendingId,
createdAt: nowMs(),
sessionId: localSessionId,
agentSessionId,
availableReplies: availablePermissionReplies(request.options),
options: request.options.map(clonePermissionOption),
toolCall: clonePermissionToolCall(request.toolCall),
rawRequest: clonePermissionRequest(request),
};
return await new Promise<RequestPermissionResponse>((resolve, reject) => {
this.pendingPermissionRequests.set(pendingId, {
id: pendingId,
sessionId: localSessionId,
request: clonePermissionRequest(request),
resolve,
reject,
});
try {
for (const listener of listeners) {
listener(permissionRequest);
}
} catch (error) {
this.pendingPermissionRequests.delete(pendingId);
reject(error);
}
});
}
private resolvePendingPermission(permissionId: string, response: RequestPermissionResponse): void {
const pending = this.pendingPermissionRequests.get(permissionId);
if (!pending) {
throw new Error(`permission '${permissionId}' not found`);
}
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(response);
}
private cancelPendingPermissionsForSession(sessionId: string): void {
for (const [permissionId, pending] of this.pendingPermissionRequests) {
if (pending.sessionId !== sessionId) {
continue;
}
this.pendingPermissionRequests.delete(permissionId);
pending.resolve(cancelledPermissionResponse());
}
}
private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.requestRaw(method, path, {
query: options.query,
@ -1922,6 +2168,14 @@ export class SandboxAgent {
}
}
type PendingPermissionRequestState = {
id: string;
sessionId: string;
request: RequestPermissionRequest;
resolve: (response: RequestPermissionResponse) => void;
reject: (reason?: unknown) => void;
};
type QueryValue = string | number | boolean | null | undefined;
type RequestOptions = {
@ -2166,6 +2420,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage {
return JSON.parse(JSON.stringify(envelope)) as AnyMessage;
}
function clonePermissionRequest(request: RequestPermissionRequest): RequestPermissionRequest {
return JSON.parse(JSON.stringify(request)) as RequestPermissionRequest;
}
function clonePermissionResponse(response: RequestPermissionResponse): RequestPermissionResponse {
return JSON.parse(JSON.stringify(response)) as RequestPermissionResponse;
}
function clonePermissionOption(option: PermissionOption): SessionPermissionRequestOption {
return {
optionId: option.optionId,
name: option.name,
kind: option.kind,
};
}
function clonePermissionToolCall(toolCall: RequestPermissionRequest["toolCall"]): RequestPermissionRequest["toolCall"] {
return JSON.parse(JSON.stringify(toolCall)) as RequestPermissionRequest["toolCall"];
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null;
}
@ -2314,6 +2588,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string
.filter((value): value is string => !!value);
}
function deriveModesFromConfigOptions(
configOptions: SessionConfigOption[] | undefined,
): SessionModeState | null {
if (!configOptions || configOptions.length === 0) {
return null;
}
const modeOption = findConfigOptionByCategory(configOptions, "mode");
if (!modeOption || !Array.isArray(modeOption.options)) {
return null;
}
const availableModes = modeOption.options
.flatMap((entry) => flattenConfigOptions(entry))
.map((entry) => ({
id: entry.value,
name: entry.name,
description: entry.description ?? null,
}));
return {
currentModeId:
typeof modeOption.currentValue === "string" && modeOption.currentValue.length > 0
? modeOption.currentValue
: availableModes[0]?.id ?? "",
availableModes,
};
}
function applyCurrentMode(
modes: SessionModeState | null | undefined,
currentModeId: string,
@ -2344,6 +2647,25 @@ function applyConfigOptionValue(
return updated;
}
function flattenConfigOptions(entry: unknown): Array<{ value: string; name: string; description?: string }> {
if (!isRecord(entry)) {
return [];
}
if (typeof entry.value === "string" && typeof entry.name === "string") {
return [
{
value: entry.value,
name: entry.name,
description: typeof entry.description === "string" ? entry.description : undefined,
},
];
}
if (!Array.isArray(entry.options)) {
return [];
}
return entry.options.flatMap((nested) => flattenConfigOptions(nested));
}
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
return null;
@ -2368,6 +2690,60 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
return JSON.parse(JSON.stringify(value)) as SessionModeState;
}
function availablePermissionReplies(options: PermissionOption[]): PermissionReply[] {
const replies = new Set<PermissionReply>();
for (const option of options) {
if (option.kind === "allow_once") {
replies.add("once");
} else if (option.kind === "allow_always") {
replies.add("always");
} else if (option.kind === "reject_once" || option.kind === "reject_always") {
replies.add("reject");
}
}
return [...replies];
}
function permissionReplyToResponse(
permissionId: string,
request: RequestPermissionRequest,
reply: PermissionReply,
): RequestPermissionResponse {
const preferredKinds: PermissionOptionKind[] =
reply === "once"
? ["allow_once"]
: reply === "always"
? ["allow_always", "allow_once"]
: ["reject_once", "reject_always"];
const selected = preferredKinds
.map((kind) => request.options.find((option) => option.kind === kind))
.find((option): option is PermissionOption => Boolean(option));
if (!selected) {
throw new UnsupportedPermissionReplyError(
permissionId,
reply,
availablePermissionReplies(request.options),
);
}
return {
outcome: {
outcome: "selected",
optionId: selected.optionId,
},
};
}
function cancelledPermissionResponse(): RequestPermissionResponse {
return {
outcome: {
outcome: "cancelled",
},
};
}
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
return (
isRecord(value) &&

View file

@ -4,6 +4,7 @@ export {
SandboxAgent,
SandboxAgentError,
Session,
UnsupportedPermissionReplyError,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
@ -28,6 +29,10 @@ export type {
SessionResumeOrCreateRequest,
SessionSendOptions,
SessionEventListener,
PermissionReply,
PermissionRequestListener,
SessionPermissionRequest,
SessionPermissionRequestOption,
} from "./client.ts";
export type { InspectorUrlOptions } from "./inspector.ts";

View file

@ -25,10 +25,12 @@ export function prepareMockAgentDataHome(dataHome: string): Record<string, strin
runtimeEnv.XDG_DATA_HOME = dataHome;
}
const nodeScript = String.raw`#!/usr/bin/env node
const nodeScript = String.raw`#!/usr/bin/env node
const { createInterface } = require("node:readline");
let nextSession = 0;
let nextPermission = 0;
const pendingPermissions = new Map();
function emit(value) {
process.stdout.write(JSON.stringify(value) + "\n");
@ -65,6 +67,38 @@ rl.on("line", (line) => {
const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
const method = hasMethod ? msg.method : undefined;
if (!hasMethod && hasId) {
const pending = pendingPermissions.get(String(msg.id));
if (pending) {
pendingPermissions.delete(String(msg.id));
const outcome = msg?.result?.outcome;
const optionId = outcome?.outcome === "selected" ? outcome.optionId : "cancelled";
const suffix = optionId === "reject-once" ? "rejected" : "approved";
emit({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: pending.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "mock permission " + suffix + ": " + optionId,
},
},
},
});
emit({
jsonrpc: "2.0",
id: pending.promptId,
result: {
stopReason: "end_turn",
},
});
}
return;
}
if (method === "session/prompt") {
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
const text = firstText(msg?.params?.prompt);
@ -82,6 +116,51 @@ rl.on("line", (line) => {
},
},
});
if (text.includes("permission")) {
nextPermission += 1;
const permissionId = "permission-" + nextPermission;
pendingPermissions.set(permissionId, {
promptId: msg.id,
sessionId,
});
emit({
jsonrpc: "2.0",
id: permissionId,
method: "session/request_permission",
params: {
sessionId,
toolCall: {
toolCallId: "tool-call-" + nextPermission,
title: "Write mock.txt",
kind: "edit",
status: "pending",
locations: [{ path: "/tmp/mock.txt" }],
rawInput: {
path: "/tmp/mock.txt",
content: "hello",
},
},
options: [
{
kind: "allow_once",
name: "Allow once",
optionId: "allow-once",
},
{
kind: "allow_always",
name: "Always allow",
optionId: "allow-always",
},
{
kind: "reject_once",
name: "Reject",
optionId: "reject-once",
},
],
},
});
}
}
if (!hasMethod || !hasId) {
@ -117,6 +196,10 @@ rl.on("line", (line) => {
}
if (method === "session/prompt") {
const text = firstText(msg?.params?.prompt);
if (text.includes("permission")) {
return;
}
emit({
jsonrpc: "2.0",
id: msg.id,

View file

@ -512,10 +512,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
const session = await sdk.createSession({ agent: "mock" });
await expect(session.send("session/cancel")).rejects.toThrow(
await expect(session.rawSend("session/cancel")).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
await expect(sdk.rawSendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
@ -625,6 +625,43 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("surfaces ACP permission requests and maps approve/reject replies", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
const permissionIds: string[] = [];
const permissionTexts: string[] = [];
const offPermissions = session.onPermissionRequest((request) => {
permissionIds.push(request.id);
const reply = permissionIds.length === 1 ? "reject" : "always";
void session.respondPermission(request.id, reply);
});
const offEvents = session.onEvent((event) => {
const text = (event.payload as any)?.params?.update?.content?.text;
if (typeof text === "string" && text.startsWith("mock permission ")) {
permissionTexts.push(text);
}
});
await session.prompt([{ type: "text", text: "trigger permission request one" }]);
await session.prompt([{ type: "text", text: "trigger permission request two" }]);
await waitFor(() => (permissionIds.length === 2 ? permissionIds : undefined));
await waitFor(() => (permissionTexts.length === 2 ? permissionTexts : undefined));
expect(permissionTexts[0]).toContain("rejected");
expect(permissionTexts[1]).toContain("approved");
offEvents();
offPermissions();
await sdk.dispose();
});
it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,