Merge remote-tracking branch 'origin/main' into factory/onboarding-app-shell

# Conflicts:
#	factory/CLAUDE.md
#	factory/packages/backend/package.json
#	factory/packages/client/package.json
#	pnpm-lock.yaml
#	research/acp/friction.md
This commit is contained in:
Nathan Flurry 2026-03-10 21:59:12 -07:00
commit 0a8fda040b
57 changed files with 1465 additions and 503 deletions

View file

@ -27,6 +27,8 @@
- ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP. - ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP.
- Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities). - Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities).
- Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP. - Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP.
- Do not make Sandbox Agent core flows depend on ACP client implementations of `fs/*` or `terminal/*`; in practice those client-side capabilities are often incomplete or inconsistent.
- ACP-native filesystem and terminal methods are also too limited for Sandbox Agent host/runtime needs, so prefer the native HTTP APIs for richer behavior.
- Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP: - Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP:
- These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior. - These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior.
- They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream. - They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream.
@ -41,6 +43,12 @@
- Canonical extension namespace/domain string is `sandboxagent.dev` (no hyphen). - Canonical extension namespace/domain string is `sandboxagent.dev` (no hyphen).
- Canonical custom ACP extension method prefix is `_sandboxagent/...` (no hyphen). - Canonical custom ACP extension method prefix is `_sandboxagent/...` (no hyphen).
## Docs Terminology
- Never mention "ACP" in user-facing docs (`docs/**/*.mdx`) except in docs that are specifically about ACP itself (e.g. `docs/acp-http-client.mdx`).
- Never expose underlying protocol method names (e.g. `session/request_permission`, `session/create`, `_sandboxagent/session/detach`) in non-ACP docs. Describe the behavior in user-facing terms instead.
- Do not describe the underlying protocol implementation in docs. Only document the SDK surface (methods, types, options). ACP protocol details belong exclusively in ACP-specific pages.
## Architecture (Brief) ## Architecture (Brief)
- HTTP contract and problem/error mapping: `server/packages/sandbox-agent/src/router.rs` - HTTP contract and problem/error mapping: `server/packages/sandbox-agent/src/router.rs`
@ -54,10 +62,15 @@
- `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers. - `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers.
- `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers. - `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers.
- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`. - `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`.
- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`. - Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `rawSendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, `getSessionModes`, `respondPermission`, `rawRespondPermission`, and `onPermissionRequest`.
- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`. - `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`.
- Cleanup is `sdk.dispose()`. - Cleanup is `sdk.dispose()`.
### TypeScript SDK Naming Conventions
- Use `respond<Thing>(id, reply)` for SDK methods that reply to an agent-initiated request (e.g. `respondPermission`). This is the standard pattern for answering any inbound JSON-RPC request from the agent.
- Prefix raw/low-level escape hatches with `raw` (e.g. `rawRespondPermission`, `rawSend`). These accept protocol-level types directly and bypass SDK abstractions.
### Docs Source Of Truth ### Docs Source Of Truth
- For TypeScript docs/examples, source of truth is implementation in: - For TypeScript docs/examples, source of truth is implementation in:
@ -70,8 +83,17 @@
- `server/packages/sandbox-agent/src/cli.rs` - `server/packages/sandbox-agent/src/cli.rs`
- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs). - Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs).
## ACP Protocol Compliance
- Before adding any new ACP method, property, or config option category to the SDK, verify it exists in the ACP spec at `https://agentclientprotocol.com/llms-full.txt`.
- Valid `SessionConfigOptionCategory` values are: `mode`, `model`, `thought_level`, `other`, or custom categories prefixed with `_` (e.g. `_permission_mode`).
- Do not invent ACP properties or categories (e.g. `permission_mode` is not a valid ACP category — use `_permission_mode` if it's a custom extension, or use existing ACP mechanisms like `session/set_mode`).
- `NewSessionRequest` only has `_meta`, `cwd`, and `mcpServers`. Do not add non-ACP fields to it.
- Sandbox Agent SDK abstractions (like `SessionCreateRequest`) may add convenience properties, but must clearly map to real ACP methods internally and not send fabricated fields over the wire.
## Source Documents ## Source Documents
- ACP protocol specification (full LLM-readable reference): `https://agentclientprotocol.com/llms-full.txt`
- `~/misc/acp-docs/schema/schema.json` - `~/misc/acp-docs/schema/schema.json`
- `~/misc/acp-docs/schema/meta.json` - `~/misc/acp-docs/schema/meta.json`
- `research/acp/spec.md` - `research/acp/spec.md`

View file

@ -3,7 +3,7 @@ resolver = "2"
members = ["server/packages/*", "gigacode"] members = ["server/packages/*", "gigacode"]
[workspace.package] [workspace.package]
version = "0.3.0" version = "0.3.1"
edition = "2021" edition = "2021"
authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ] authors = [ "Rivet Gaming, LLC <developer@rivet.gg>" ]
license = "Apache-2.0" license = "Apache-2.0"
@ -12,13 +12,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports
[workspace.dependencies] [workspace.dependencies]
# Internal crates # Internal crates
sandbox-agent = { version = "0.3.0", path = "server/packages/sandbox-agent" } sandbox-agent = { version = "0.3.1", path = "server/packages/sandbox-agent" }
sandbox-agent-error = { version = "0.3.0", path = "server/packages/error" } sandbox-agent-error = { version = "0.3.1", path = "server/packages/error" }
sandbox-agent-agent-management = { version = "0.3.0", path = "server/packages/agent-management" } sandbox-agent-agent-management = { version = "0.3.1", path = "server/packages/agent-management" }
sandbox-agent-agent-credentials = { version = "0.3.0", path = "server/packages/agent-credentials" } sandbox-agent-agent-credentials = { version = "0.3.1", path = "server/packages/agent-credentials" }
sandbox-agent-opencode-adapter = { version = "0.3.0", path = "server/packages/opencode-adapter" } sandbox-agent-opencode-adapter = { version = "0.3.1", path = "server/packages/opencode-adapter" }
sandbox-agent-opencode-server-manager = { version = "0.3.0", path = "server/packages/opencode-server-manager" } sandbox-agent-opencode-server-manager = { version = "0.3.1", path = "server/packages/opencode-server-manager" }
acp-http-adapter = { version = "0.3.0", path = "server/packages/acp-http-adapter" } acp-http-adapter = { version = "0.3.1", path = "server/packages/acp-http-adapter" }
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View file

@ -118,7 +118,6 @@ const agents = await client.listAgents();
await client.createSession("demo", { await client.createSession("demo", {
agent: "codex", agent: "codex",
agentMode: "default", agentMode: "default",
permissionMode: "plan",
}); });
await client.postMessage("demo", { message: "Hello from the SDK." }); await client.postMessage("demo", { message: "Hello from the SDK." });
@ -128,9 +127,7 @@ for await (const event of client.streamEvents("demo", { offset: 0 })) {
} }
``` ```
`permissionMode: "acceptEdits"` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents. [SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
[SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Building a Chat UI](https://sandboxagent.dev/docs/building-chat-ui) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions)
### HTTP Server ### HTTP Server

View file

@ -125,9 +125,45 @@ for (const opt of options) {
await session.setConfigOption("some-agent-option", "value"); await session.setConfigOption("some-agent-option", "value");
``` ```
## Handle permission requests
For agents that request tool-use permissions, register a permission listener and reply with `once`, `always`, or `reject`:
```ts
const session = await sdk.createSession({
agent: "claude",
mode: "default",
});
session.onPermissionRequest((request) => {
console.log(request.toolCall.title, request.availableReplies);
void session.respondPermission(request.id, "once");
});
await session.prompt([
{ type: "text", text: "Create ./permission-example.txt with the text hello." },
]);
```
### Auto-approving permissions
To auto-approve all permission requests, respond with `"once"` or `"always"` in your listener:
```ts
session.onPermissionRequest((request) => {
void session.respondPermission(request.id, "always");
});
```
See `examples/permissions/src/index.ts` for a complete permissions example that works with Claude and Codex.
<Info>
Some agents like Claude allow configuring permission behavior through modes (e.g. `bypassPermissions`, `acceptEdits`). We recommend leaving the mode as `default` and handling permission decisions explicitly in `onPermissionRequest` instead.
</Info>
## Destroy a session ## Destroy a session
```ts ```ts
await sdk.destroySession(session.id); await sdk.destroySession(session.id);
``` ```

View file

@ -58,4 +58,4 @@ Use the filesystem API to upload files, then include file references in prompt c
- Use absolute file URIs in `resource_link` blocks. - Use absolute file URIs in `resource_link` blocks.
- If `mimeType` is omitted, the agent/runtime may infer a default. - If `mimeType` is omitted, the agent/runtime may infer a default.
- Support for non-text resources depends on each agent's ACP prompt capabilities. - Support for non-text resources depends on each agent's prompt capabilities.

View file

@ -1,370 +0,0 @@
---
title: "Building a Chat UI"
description: "Build a chat interface using the universal event stream."
icon: "comments"
---
## Setup
### List agents
```ts
const { agents } = await client.listAgents();
// Each agent exposes feature coverage via `capabilities` to determine what UI to show
const claude = agents.find((a) => a.id === "claude");
if (claude?.capabilities.permissions) {
// Show permission approval UI
}
if (claude?.capabilities.questions) {
// Show question response UI
}
```
### Create a session
```ts
const sessionId = `session-${crypto.randomUUID()}`;
await client.createSession(sessionId, {
agent: "claude",
agentMode: "code", // Optional: agent-specific mode
permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default)
model: "claude-sonnet-4", // Optional: model override
});
```
### Send a message
```ts
await client.postMessage(sessionId, { message: "Hello, world!" });
```
### Stream events
Three options for receiving events:
```ts
// Option 1: SSE (recommended for real-time UI)
const stream = client.streamEvents(sessionId, { offset: 0 });
for await (const event of stream) {
handleEvent(event);
}
// Option 2: Polling
const { events, hasMore } = await client.getEvents(sessionId, { offset: 0 });
events.forEach(handleEvent);
// Option 3: Turn streaming (send + stream in one call)
const stream = client.streamTurn(sessionId, { message: "Hello" });
for await (const event of stream) {
handleEvent(event);
}
```
Use `offset` to track the last seen `sequence` number and resume from where you left off.
---
## Handling Events
### Bare minimum
Handle item lifecycle plus turn lifecycle to render a basic chat:
```ts
type ItemState = {
item: UniversalItem;
deltas: string[];
};
const items = new Map<string, ItemState>();
let turnInProgress = false;
function handleEvent(event: UniversalEvent) {
switch (event.type) {
case "turn.started": {
turnInProgress = true;
break;
}
case "turn.ended": {
turnInProgress = false;
break;
}
case "item.started": {
const { item } = event.data as ItemEventData;
items.set(item.item_id, { item, deltas: [] });
break;
}
case "item.delta": {
const { item_id, delta } = event.data as ItemDeltaData;
const state = items.get(item_id);
if (state) {
state.deltas.push(delta);
}
break;
}
case "item.completed": {
const { item } = event.data as ItemEventData;
const state = items.get(item.item_id);
if (state) {
state.item = item;
state.deltas = []; // Clear deltas, use final content
}
break;
}
}
}
```
When rendering:
- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.).
- Use `item.status === "in_progress"` for per-item streaming state.
```ts
function renderItem(state: ItemState) {
const { item, deltas } = state;
const isItemLoading = item.status === "in_progress";
// For streaming text, combine item content with accumulated deltas
const text = item.content
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
const streamedText = text + deltas.join("");
return {
content: streamedText,
isItemLoading,
isTurnLoading: turnInProgress,
role: item.role,
kind: item.kind,
};
}
```
### Extra events
Handle these for a complete implementation:
```ts
function handleEvent(event: UniversalEvent) {
switch (event.type) {
// ... bare minimum events above ...
case "session.started": {
// Session is ready
break;
}
case "session.ended": {
const { reason, terminated_by } = event.data as SessionEndedData;
// Disable input, show end reason
// reason: "completed" | "error" | "terminated"
// terminated_by: "agent" | "daemon"
break;
}
case "error": {
const { message, code } = event.data as ErrorData;
// Display error to user
break;
}
case "agent.unparsed": {
const { error, location } = event.data as AgentUnparsedData;
// Parsing failure - treat as bug in development
console.error(`Parse error at ${location}: ${error}`);
break;
}
}
}
```
### Content parts
Each item has `content` parts. Render based on `type`:
```ts
function renderContentPart(part: ContentPart) {
switch (part.type) {
case "text":
return <Markdown>{part.text}</Markdown>;
case "tool_call":
return <ToolCall name={part.name} args={part.arguments} />;
case "tool_result":
return <ToolResult output={part.output} />;
case "file_ref":
return <FileChange path={part.path} action={part.action} diff={part.diff} />;
case "reasoning":
return <Reasoning>{part.text}</Reasoning>;
case "status":
return <Status label={part.label} detail={part.detail} />;
case "image":
return <Image src={part.path} />;
}
}
```
---
## Handling Permissions
When `permission.requested` arrives, show an approval UI:
```ts
const pendingPermissions = new Map<string, PermissionEventData>();
function handleEvent(event: UniversalEvent) {
if (event.type === "permission.requested") {
const data = event.data as PermissionEventData;
pendingPermissions.set(data.permission_id, data);
}
if (event.type === "permission.resolved") {
const data = event.data as PermissionEventData;
pendingPermissions.delete(data.permission_id);
}
}
// User clicks approve/deny
async function replyPermission(id: string, reply: "once" | "always" | "reject") {
await client.replyPermission(sessionId, id, { reply });
pendingPermissions.delete(id);
}
```
Render permission requests:
```ts
function PermissionRequest({ data }: { data: PermissionEventData }) {
return (
<div>
<p>Allow: {data.action}</p>
<button onClick={() => replyPermission(data.permission_id, "once")}>
Allow Once
</button>
<button onClick={() => replyPermission(data.permission_id, "always")}>
Always Allow
</button>
<button onClick={() => replyPermission(data.permission_id, "reject")}>
Reject
</button>
</div>
);
}
```
---
## Handling Questions
When `question.requested` arrives, show a selection UI:
```ts
const pendingQuestions = new Map<string, QuestionEventData>();
function handleEvent(event: UniversalEvent) {
if (event.type === "question.requested") {
const data = event.data as QuestionEventData;
pendingQuestions.set(data.question_id, data);
}
if (event.type === "question.resolved") {
const data = event.data as QuestionEventData;
pendingQuestions.delete(data.question_id);
}
}
// User selects answer(s)
async function answerQuestion(id: string, answers: string[][]) {
await client.replyQuestion(sessionId, id, { answers });
pendingQuestions.delete(id);
}
async function rejectQuestion(id: string) {
await client.rejectQuestion(sessionId, id);
pendingQuestions.delete(id);
}
```
Render question requests:
```ts
function QuestionRequest({ data }: { data: QuestionEventData }) {
const [selected, setSelected] = useState<string[]>([]);
return (
<div>
<p>{data.prompt}</p>
{data.options.map((option) => (
<label key={option}>
<input
type="checkbox"
checked={selected.includes(option)}
onChange={(e) => {
if (e.target.checked) {
setSelected([...selected, option]);
} else {
setSelected(selected.filter((s) => s !== option));
}
}}
/>
{option}
</label>
))}
<button onClick={() => answerQuestion(data.question_id, [selected])}>
Submit
</button>
<button onClick={() => rejectQuestion(data.question_id)}>
Reject
</button>
</div>
);
}
```
---
## Testing with Mock Agent
The `mock` agent lets you test UI behaviors without external credentials:
```ts
await client.createSession("test-session", { agent: "mock" });
```
Send `help` to see available commands:
| Command | Tests |
|---------|-------|
| `help` | Lists all commands |
| `demo` | Full UI coverage sequence with markers |
| `markdown` | Streaming markdown rendering |
| `tool` | Tool call + result with file refs |
| `status` | Status item updates |
| `image` | Image content part |
| `permission` | Permission request flow |
| `question` | Question request flow |
| `error` | Error + unparsed events |
| `end` | Session ended event |
| `echo <text>` | Echo text as assistant message |
Any unrecognized text is echoed back as an assistant message.
---
## Reference Implementation
The [Inspector UI](https://github.com/rivet-dev/sandbox-agent/blob/main/frontend/packages/inspector/src/App.tsx)
is a complete reference showing session management, event rendering, and HITL flows.

View file

@ -181,7 +181,7 @@ sandbox-agent api agents list
#### api agents report #### api agents report
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category. Emit a JSON report of available models, modes, and thought levels for every agent, grouped by category.
```bash ```bash
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq . sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .

View file

@ -61,7 +61,7 @@ When prompting, Sandbox Agent does not pre-validate provider credentials. Agent-
### API ### API
`GET /v1/agents` includes `credentialsAvailable` per agent. `sdk.listAgents()` includes `credentialsAvailable` per agent.
```json ```json
{ {

View file

@ -115,8 +115,8 @@ This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path an
## Troubleshooting streaming updates ## Troubleshooting streaming updates
If you only receive: If you only receive:
- outbound `session/prompt` - the outbound prompt request
- final `{ stopReason: "end_turn" }` - the final `{ stopReason: "end_turn" }` response
then the streamed update channel dropped. In Cloudflare sandbox paths, this is typically caused by forwarding `AbortSignal` from SDK fetch init into `containerFetch(...)`. then the streamed update channel dropped. In Cloudflare sandbox paths, this is typically caused by forwarding `AbortSignal` from SDK fetch init into `containerFetch(...)`.

View file

@ -34,6 +34,7 @@ console.log(url);
- Event JSON inspector - Event JSON inspector
- Prompt testing - Prompt testing
- Request/response debugging - Request/response debugging
- Interactive permission prompts (approve, always-allow, or reject tool-use requests)
- Process management (create, stop, kill, delete, view logs) - Process management (create, stop, kill, delete, view logs)
- Interactive PTY terminal for tty processes - Interactive PTY terminal for tty processes
- One-shot command execution - One-shot command execution

View file

@ -6,8 +6,6 @@ icon: "database"
Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database. Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database.
See the [Building a Chat UI](/building-chat-ui) guide for understanding session lifecycle events like `session.started` and `session.ended`.
## Recommended approach ## Recommended approach
1. Store events to your database as they arrive 1. Store events to your database as they arrive
@ -18,11 +16,11 @@ This prevents duplicate writes and lets you recover from disconnects.
## Receiving Events ## Receiving Events
Two ways to receive events: SSE streaming (recommended) or polling. Two ways to receive events: streaming (recommended) or polling.
### Streaming ### Streaming
Use SSE for real-time events with automatic reconnection support. Use streaming for real-time events with automatic reconnection support.
```typescript ```typescript
import { SandboxAgentClient } from "sandbox-agent"; import { SandboxAgentClient } from "sandbox-agent";
@ -44,7 +42,7 @@ for await (const event of client.streamEvents("my-session", { offset })) {
### Polling ### Polling
If you can't use SSE streaming, poll the events endpoint: If you can't use streaming, poll the events endpoint:
```typescript ```typescript
const lastEvent = await db.getLastEvent("my-session"); const lastEvent = await db.getLastEvent("my-session");
@ -244,7 +242,7 @@ const events = await redis.lrange(`session:${sessionId}`, offset, -1);
## Handling disconnects ## Handling disconnects
The SSE stream may disconnect due to network issues. Handle reconnection gracefully: The event stream may disconnect due to network issues. Handle reconnection gracefully:
```typescript ```typescript
async function streamWithRetry(sessionId: string) { async function streamWithRetry(sessionId: string) {

View file

@ -9,7 +9,7 @@ The process API supports:
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code - **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes - **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
- **Log streaming** — fetch buffered logs or follow live output via SSE - **Log streaming** — fetch buffered logs or follow live output
- **Terminals** — full PTY support with bidirectional WebSocket I/O - **Terminals** — full PTY support with bidirectional WebSocket I/O
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime - **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
@ -155,7 +155,7 @@ curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
``` ```
</CodeGroup> </CodeGroup>
### Follow logs via SSE ### Follow logs
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives. Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.

View file

@ -138,6 +138,19 @@ const options = await session.getConfigOptions();
const modes = await session.getModes(); const modes = await session.getModes();
``` ```
Handle permission requests from agents that ask before executing tools:
```ts
const claude = await sdk.createSession({
agent: "claude",
mode: "default",
});
claude.onPermissionRequest((request) => {
void claude.respondPermission(request.id, "once");
});
```
See [Agent Sessions](/agent-sessions) for full details on config options and error handling. See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
## Events ## Events
@ -209,6 +222,6 @@ Parameters:
- `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL - `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL
- `token` (optional): Bearer token for authenticated servers - `token` (optional): Bearer token for authenticated servers
- `headers` (optional): Additional request headers - `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls - `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait - `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`

View file

@ -71,7 +71,7 @@ export function App() {
if (event.type === "permission.requested") { if (event.type === "permission.requested") {
const data = event.data as PermissionEventData; const data = event.data as PermissionEventData;
log(`[Auto-approved] ${data.action}`); log(`[Auto-approved] ${data.action}`);
await client.replyPermission(sessionIdRef.current, data.permission_id, { reply: "once" }); await client.respondPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
} }
// Reject questions (don't support interactive input) // Reject questions (don't support interactive input)

View file

@ -0,0 +1,18 @@
{
"name": "@sandbox-agent/example-permissions",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"commander": "^12.1.0",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}

View file

@ -0,0 +1,192 @@
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { Command } from "commander";
import {
SandboxAgent,
type PermissionReply,
type SessionPermissionRequest,
} from "sandbox-agent";
const options = parseOptions();
const agent = options.agent.trim().toLowerCase();
const autoReply = parsePermissionReply(options.reply);
const promptText =
options.prompt?.trim() ||
`Create ./permission-example.txt with the text 'hello from the ${agent} permissions example'.`;
const sdk = await SandboxAgent.start({
spawn: {
enabled: true,
log: "inherit",
},
});
try {
await sdk.installAgent(agent);
const agents = await sdk.listAgents({ config: true });
const selectedAgent = agents.agents.find((entry) => entry.id === agent);
const configOptions = Array.isArray(selectedAgent?.configOptions)
? (selectedAgent.configOptions as Array<{ category?: string; currentValue?: string; options?: unknown[] }>)
: [];
const modeOption = configOptions.find((option) => option.category === "mode");
const availableModes = extractOptionValues(modeOption);
const mode =
options.mode?.trim() ||
(typeof modeOption?.currentValue === "string" ? modeOption.currentValue : "") ||
availableModes[0] ||
"";
console.log(`Agent: ${agent}`);
console.log(`Mode: ${mode || "(default)"}`);
if (availableModes.length > 0) {
console.log(`Available modes: ${availableModes.join(", ")}`);
}
console.log(`Working directory: ${process.cwd()}`);
console.log(`Prompt: ${promptText}`);
if (autoReply) {
console.log(`Automatic permission reply: ${autoReply}`);
} else {
console.log("Interactive permission replies enabled.");
}
const session = await sdk.createSession({
agent,
...(mode ? { mode } : {}),
sessionInit: {
cwd: process.cwd(),
mcpServers: [],
},
});
const rl = autoReply
? null
: createInterface({
input,
output,
});
session.onPermissionRequest((request: SessionPermissionRequest) => {
void handlePermissionRequest(session, request, autoReply, rl);
});
const response = await session.prompt([{ type: "text", text: promptText }]);
console.log(`Prompt finished with stopReason=${response.stopReason}`);
await rl?.close();
} finally {
await sdk.dispose();
}
async function handlePermissionRequest(
session: {
respondPermission(permissionId: string, reply: PermissionReply): Promise<void>;
},
request: SessionPermissionRequest,
auto: PermissionReply | null,
rl: ReturnType<typeof createInterface> | null,
): Promise<void> {
const reply = auto ?? (await promptForReply(request, rl));
console.log(`Permission ${reply}: ${request.toolCall.title ?? request.toolCall.toolCallId}`);
await session.respondPermission(request.id, reply);
}
async function promptForReply(
request: SessionPermissionRequest,
rl: ReturnType<typeof createInterface> | null,
): Promise<PermissionReply> {
if (!rl) {
return "reject";
}
const title = request.toolCall.title ?? request.toolCall.toolCallId;
const available = request.availableReplies;
console.log("");
console.log(`Permission request: ${title}`);
console.log(`Available replies: ${available.join(", ")}`);
const answer = (await rl.question("Reply [once|always|reject]: ")).trim().toLowerCase();
const parsed = parsePermissionReply(answer);
if (parsed && available.includes(parsed)) {
return parsed;
}
console.log("Invalid reply, defaulting to reject.");
return "reject";
}
function extractOptionValues(option: { options?: unknown[] } | undefined): string[] {
if (!option?.options) {
return [];
}
const values: string[] = [];
for (const entry of option.options) {
if (!entry || typeof entry !== "object") {
continue;
}
const value = "value" in entry && typeof entry.value === "string" ? entry.value : null;
if (value) {
values.push(value);
continue;
}
if (!("options" in entry) || !Array.isArray(entry.options)) {
continue;
}
for (const nested of entry.options) {
if (!nested || typeof nested !== "object") {
continue;
}
const nestedValue =
"value" in nested && typeof nested.value === "string" ? nested.value : null;
if (nestedValue) {
values.push(nestedValue);
}
}
}
return [...new Set(values)];
}
function parsePermissionReply(value: string | undefined): PermissionReply | null {
if (!value) {
return null;
}
switch (value.trim().toLowerCase()) {
case "once":
return "once";
case "always":
return "always";
case "reject":
case "deny":
return "reject";
default:
return null;
}
}
function parseOptions(): {
agent: string;
mode?: string;
prompt?: string;
reply?: string;
} {
const argv = process.argv.slice(2);
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
const program = new Command();
program
.name("permissions")
.description("Run a permissions example against an agent session.")
.requiredOption("--agent <agent>", "Agent to run, for example 'claude' or 'codex'")
.option("--mode <mode>", "Mode to configure for the session (uses agent default if omitted)")
.option("--prompt <text>", "Prompt to send after the session starts")
.option("--reply <reply>", "Automatically answer permission prompts with once, always, or reject");
program.parse(normalizedArgv, { from: "user" });
return program.opts<{
agent: string;
mode?: string;
prompt?: string;
reply?: string;
}>();
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}

View file

@ -109,7 +109,11 @@ For all Rivet/RivetKit implementation:
- Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead. - Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead.
- Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys. - Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys.
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`). 3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
4. Use published RivetKit npm packages (`"rivetkit": "^2.1.6"` or later). Do not use `link:` dependencies pointing outside the workspace. 4. Use published RivetKit npm packages (`"rivetkit": "2.1.6"` by default). Do not use `link:` dependencies pointing outside the workspace unless you are doing a temporary local RivetKit debugging pass.
5. Temporary local relink is allowed only when actively debugging RivetKit against the local checkout at `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`.
- Preferred link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
- Before using the local checkout, build RivetKit from that repo so the linked package has fresh output.
- After the debugging pass, switch dependencies back to the published package version.
## Inspector HTTP API (Workflow Debugging) ## Inspector HTTP API (Workflow Debugging)

View file

@ -22,7 +22,7 @@
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.5",
"hono": "^4.11.9", "hono": "^4.11.9",
"pino": "^10.3.1", "pino": "^10.3.1",
"rivetkit": "^2.1.6", "rivetkit": "2.1.6",
"sandbox-agent": "workspace:*", "sandbox-agent": "workspace:*",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.1.5" "zod": "^4.1.5"

View file

@ -172,7 +172,7 @@ export class SandboxAgentClient {
// and waiting here can stall session creation long enough to trip task init // and waiting here can stall session creation long enough to trip task init
// step timeouts even though the session itself was created. // step timeouts even though the session itself was created.
if (modeId) { if (modeId) {
void session.send("session/set_mode", { modeId }).catch(() => { void session.rawSend("session/set_mode", { modeId }).catch(() => {
// ignore // ignore
}); });
} }
@ -243,7 +243,7 @@ export class SandboxAgentClient {
const modeId = modeIdForAgent(this.agent); const modeId = modeIdForAgent(this.agent);
// Keep mode update best-effort and non-blocking for the same reason as createSession. // Keep mode update best-effort and non-blocking for the same reason as createSession.
if (modeId) { if (modeId) {
void session.send("session/set_mode", { modeId }).catch(() => { void session.rawSend("session/set_mode", { modeId }).catch(() => {
// ignore // ignore
}); });
} }
@ -290,7 +290,7 @@ export class SandboxAgentClient {
} }
const session = await sdk.resumeSession(sessionId); const session = await sdk.resumeSession(sessionId);
await session.send("session/cancel", {}); await session.rawSend("session/cancel", {});
this.setStatus(sessionId, "idle"); this.setStatus(sessionId, "idle");
} }

View file

@ -46,7 +46,7 @@
}, },
"dependencies": { "dependencies": {
"@sandbox-agent/factory-shared": "workspace:*", "@sandbox-agent/factory-shared": "workspace:*",
"rivetkit": "^2.1.6" "rivetkit": "2.1.6"
}, },
"devDependencies": { "devDependencies": {
"tsup": "^8.5.0" "tsup": "^8.5.0"

View file

@ -1594,6 +1594,118 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Permission prompt */
.permission-prompt {
margin: 8px 0;
padding: 12px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.permission-prompt.resolved {
opacity: 0.7;
}
.permission-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.permission-icon {
color: var(--warning, #f59e0b);
flex-shrink: 0;
}
.permission-prompt.resolved .permission-icon {
color: var(--muted);
}
.permission-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.permission-description {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
padding-left: 22px;
}
.permission-actions {
display: flex;
gap: 6px;
padding-left: 22px;
}
.permission-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.permission-btn:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
}
.permission-btn.allow:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.1);
border-color: rgba(34, 197, 94, 0.3);
color: #22c55e;
}
.permission-btn.reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
.permission-btn:disabled {
cursor: default;
opacity: 0.6;
}
.permission-btn.selected {
opacity: 1;
}
.permission-btn.selected.allow {
background: rgba(34, 197, 94, 0.15);
border-color: rgba(34, 197, 94, 0.4);
color: #22c55e;
}
.permission-btn.selected.reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
color: #ef4444;
}
.permission-btn.dimmed {
opacity: 0.35;
}
.permission-auto-resolved {
font-size: 11px;
color: var(--muted);
font-style: italic;
}
.status-divider { .status-divider {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -6,6 +6,8 @@ import {
type AgentInfo, type AgentInfo,
type SessionEvent, type SessionEvent,
type Session, type Session,
type SessionPermissionRequest,
type PermissionReply,
InMemorySessionPersistDriver, InMemorySessionPersistDriver,
type SessionPersistDriver, type SessionPersistDriver,
} from "sandbox-agent"; } from "sandbox-agent";
@ -295,6 +297,11 @@ export default function App() {
const clientRef = useRef<SandboxAgent | null>(null); const clientRef = useRef<SandboxAgent | null>(null);
const activeSessionRef = useRef<Session | null>(null); const activeSessionRef = useRef<Session | null>(null);
const eventUnsubRef = useRef<(() => void) | null>(null); const eventUnsubRef = useRef<(() => void) | null>(null);
const permissionUnsubRef = useRef<(() => void) | null>(null);
const pendingPermissionsRef = useRef<Map<string, SessionPermissionRequest>>(new Map());
const permissionToolCallToIdRef = useRef<Map<string, string>>(new Map());
const [pendingPermissionIds, setPendingPermissionIds] = useState<Set<string>>(new Set());
const [resolvedPermissions, setResolvedPermissions] = useState<Map<string, string>>(new Map());
const sessionEventsCacheRef = useRef<Map<string, SessionEvent[]>>(new Map()); const sessionEventsCacheRef = useRef<Map<string, SessionEvent[]>>(new Map());
const selectedSessionIdRef = useRef(sessionId); const selectedSessionIdRef = useRef(sessionId);
const resumeInFlightSessionIdRef = useRef<string | null>(null); const resumeInFlightSessionIdRef = useRef<string | null>(null);
@ -538,8 +545,45 @@ export default function App() {
}); });
}); });
eventUnsubRef.current = unsub; eventUnsubRef.current = unsub;
// Subscribe to permission requests
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
const permUnsub = session.onPermissionRequest((request: SessionPermissionRequest) => {
if (!isCurrentSubscription()) return;
pendingPermissionsRef.current.set(request.id, request);
if (request.toolCall?.toolCallId) {
permissionToolCallToIdRef.current.set(request.toolCall.toolCallId, request.id);
}
setPendingPermissionIds((prev) => new Set([...prev, request.id]));
});
permissionUnsubRef.current = permUnsub;
}, [getClient]); }, [getClient]);
const handlePermissionReply = useCallback(async (permissionId: string, reply: PermissionReply) => {
const session = activeSessionRef.current;
if (!session) return;
try {
await session.respondPermission(permissionId, reply);
const request = pendingPermissionsRef.current.get(permissionId);
const selectedOption = request?.options.find((o) =>
reply === "always" ? o.kind === "allow_always" :
reply === "once" ? o.kind === "allow_once" :
o.kind === "reject_once" || o.kind === "reject_always"
);
setResolvedPermissions((prev) => new Map([...prev, [permissionId, selectedOption?.optionId ?? reply]]));
setPendingPermissionIds((prev) => {
const next = new Set(prev);
next.delete(permissionId);
return next;
});
} catch (error) {
pushErrorToast(error, "Failed to respond to permission request");
}
}, [pushErrorToast]);
const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => { const connectToDaemon = async (reportError: boolean, overrideEndpoint?: string) => {
setConnecting(true); setConnecting(true);
if (reportError) { if (reportError) {
@ -551,6 +595,10 @@ export default function App() {
eventUnsubRef.current(); eventUnsubRef.current();
eventUnsubRef.current = null; eventUnsubRef.current = null;
} }
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
subscriptionGenerationRef.current += 1; subscriptionGenerationRef.current += 1;
activeSessionRef.current = null; activeSessionRef.current = null;
if (clientRef.current) { if (clientRef.current) {
@ -603,6 +651,10 @@ export default function App() {
eventUnsubRef.current(); eventUnsubRef.current();
eventUnsubRef.current = null; eventUnsubRef.current = null;
} }
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
subscriptionGenerationRef.current += 1; subscriptionGenerationRef.current += 1;
activeSessionRef.current = null; activeSessionRef.current = null;
if (clientRef.current) { if (clientRef.current) {
@ -818,7 +870,7 @@ export default function App() {
// Apply mode if selected // Apply mode if selected
if (!skipPostCreateConfig && config.agentMode) { if (!skipPostCreateConfig && config.agentMode) {
try { try {
await session.send("session/set_mode", { modeId: config.agentMode }); await session.rawSend("session/set_mode", { modeId: config.agentMode });
} catch { } catch {
// Mode application is best-effort // Mode application is best-effort
} }
@ -834,7 +886,7 @@ export default function App() {
(opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string" (opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string"
); );
if (modelOption && config.model !== modelOption.currentValue) { if (modelOption && config.model !== modelOption.currentValue) {
await session.send("session/set_config_option", { await session.rawSend("session/set_config_option", {
optionId: modelOption.id, optionId: modelOption.id,
value: config.model, value: config.model,
}); });
@ -880,6 +932,10 @@ export default function App() {
eventUnsubRef.current(); eventUnsubRef.current();
eventUnsubRef.current = null; eventUnsubRef.current = null;
} }
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
activeSessionRef.current = null; activeSessionRef.current = null;
await fetchSessions(); await fetchSessions();
} catch (error) { } catch (error) {
@ -1165,6 +1221,43 @@ export default function App() {
continue; continue;
} }
if (event.sender === "agent" && method === "session/request_permission") {
const params = payload.params as {
options?: Array<{ optionId: string; name: string; kind: string }>;
toolCall?: { title?: string; toolCallId?: string; description?: string };
} | undefined;
const toolCallId = params?.toolCall?.toolCallId;
const sdkPermissionId = toolCallId
? permissionToolCallToIdRef.current.get(toolCallId)
: undefined;
const permissionId = sdkPermissionId
?? (typeof payload.id === "number" || typeof payload.id === "string"
? String(payload.id)
: event.id);
const options = (params?.options ?? []).map((o) => ({
optionId: o.optionId,
name: o.name,
kind: o.kind,
}));
const title = params?.toolCall?.title ?? params?.toolCall?.toolCallId ?? "Permission request";
const resolved = resolvedPermissions.get(permissionId);
entries.push({
id: event.id,
eventId: event.id,
kind: "permission",
time,
permission: {
permissionId,
title,
description: params?.toolCall?.description,
options,
resolved: resolved != null || sdkPermissionId == null,
selectedOptionId: resolved,
},
});
continue;
}
if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") { if (event.sender === "agent" && method === "_sandboxagent/agent/unparsed") {
const params = payload.params as { error?: string; location?: string } | undefined; const params = payload.params as { error?: string; location?: string } | undefined;
entries.push({ entries.push({
@ -1194,7 +1287,7 @@ export default function App() {
} }
return entries; return entries;
}, [events]); }, [events, resolvedPermissions]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -1202,6 +1295,10 @@ export default function App() {
eventUnsubRef.current(); eventUnsubRef.current();
eventUnsubRef.current = null; eventUnsubRef.current = null;
} }
if (permissionUnsubRef.current) {
permissionUnsubRef.current();
permissionUnsubRef.current = null;
}
}; };
}, []); }, []);
@ -1684,6 +1781,7 @@ export default function App() {
isThinking={isThinking} isThinking={isThinking}
agentId={agentId} agentId={agentId}
tokenUsage={tokenUsage} tokenUsage={tokenUsage}
onPermissionReply={handlePermissionReply}
/> />
<DebugPanel <DebugPanel

View file

@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { getMessageClass } from "./messageUtils"; import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types"; import type { TimelineEntry } from "./types";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react"; import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle, Shield, Check, X } from "lucide-react";
import MarkdownText from "./MarkdownText"; import MarkdownText from "./MarkdownText";
const ToolItem = ({ const ToolItem = ({
@ -170,6 +170,73 @@ const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEven
); );
}; };
const PermissionPrompt = ({
entry,
onPermissionReply,
}: {
entry: TimelineEntry;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
const perm = entry.permission;
if (!perm) return null;
const resolved = perm.resolved;
const selectedId = perm.selectedOptionId;
const replyForOption = (kind: string): "once" | "always" | "reject" => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const labelForKind = (kind: string, name: string): string => {
if (name) return name;
if (kind === "allow_once") return "Allow Once";
if (kind === "allow_always") return "Always Allow";
if (kind === "reject_once") return "Reject";
if (kind === "reject_always") return "Reject Always";
return kind;
};
const classForKind = (kind: string): string => {
if (kind.startsWith("allow")) return "allow";
return "reject";
};
return (
<div className={`permission-prompt ${resolved ? "resolved" : ""}`}>
<div className="permission-header">
<Shield size={14} className="permission-icon" />
<span className="permission-title">{perm.title}</span>
</div>
{perm.description && (
<div className="permission-description">{perm.description}</div>
)}
<div className="permission-actions">
{perm.options.map((opt) => {
const isSelected = resolved && selectedId === opt.optionId;
const wasRejected = resolved && !isSelected && selectedId != null;
return (
<button
key={opt.optionId}
type="button"
className={`permission-btn ${classForKind(opt.kind)} ${isSelected ? "selected" : ""} ${wasRejected ? "dimmed" : ""}`}
disabled={resolved}
onClick={() => onPermissionReply?.(perm.permissionId, replyForOption(opt.kind))}
>
{isSelected && (opt.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />)}
{labelForKind(opt.kind, opt.name)}
</button>
);
})}
{resolved && !selectedId && (
<span className="permission-auto-resolved">Auto-resolved</span>
)}
</div>
</div>
);
};
const agentLogos: Record<string, string> = { const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`, claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`, codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
@ -185,7 +252,8 @@ const ChatMessages = ({
messagesEndRef, messagesEndRef,
onEventClick, onEventClick,
isThinking, isThinking,
agentId agentId,
onPermissionReply,
}: { }: {
entries: TimelineEntry[]; entries: TimelineEntry[];
sessionError: string | null; sessionError: string | null;
@ -194,9 +262,10 @@ const ChatMessages = ({
onEventClick?: (eventId: string) => void; onEventClick?: (eventId: string) => void;
isThinking?: boolean; isThinking?: boolean;
agentId?: string; agentId?: string;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => { }) => {
// Group consecutive tool/reasoning/meta entries together // Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = []; const groupedEntries: Array<{ type: "message" | "tool-group" | "divider" | "permission"; entries: TimelineEntry[] }> = [];
let currentToolGroup: TimelineEntry[] = []; let currentToolGroup: TimelineEntry[] = [];
@ -211,7 +280,10 @@ const ChatMessages = ({
const isStatusDivider = entry.kind === "meta" && const isStatusDivider = entry.kind === "meta" &&
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? ""); ["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
if (isStatusDivider) { if (entry.kind === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", entries: [entry] });
} else if (isStatusDivider) {
flushToolGroup(); flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] }); groupedEntries.push({ type: "divider", entries: [entry] });
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) { } else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
@ -242,6 +314,17 @@ const ChatMessages = ({
); );
} }
if (group.type === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
onPermissionReply={onPermissionReply}
/>
);
}
if (group.type === "tool-group") { if (group.type === "tool-group") {
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />; return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
} }

View file

@ -57,6 +57,7 @@ const ChatPanel = ({
isThinking, isThinking,
agentId, agentId,
tokenUsage, tokenUsage,
onPermissionReply,
}: { }: {
sessionId: string; sessionId: string;
transcriptEntries: TimelineEntry[]; transcriptEntries: TimelineEntry[];
@ -87,6 +88,7 @@ const ChatPanel = ({
isThinking?: boolean; isThinking?: boolean;
agentId?: string; agentId?: string;
tokenUsage?: { used: number; size: number; cost?: number } | null; tokenUsage?: { used: number; size: number; cost?: number } | null;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => { }) => {
const [showAgentMenu, setShowAgentMenu] = useState(false); const [showAgentMenu, setShowAgentMenu] = useState(false);
const [copiedSessionId, setCopiedSessionId] = useState(false); const [copiedSessionId, setCopiedSessionId] = useState(false);
@ -258,6 +260,7 @@ const ChatPanel = ({
onEventClick={onEventClick} onEventClick={onEventClick}
isThinking={isThinking} isThinking={isThinking}
agentId={agentId} agentId={agentId}
onPermissionReply={onPermissionReply}
/> />
)} )}
</div> </div>

View file

@ -1,7 +1,13 @@
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TimelineEntry = { export type TimelineEntry = {
id: string; id: string;
eventId?: string; // Links back to the original event for navigation eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning"; kind: "message" | "tool" | "meta" | "reasoning" | "permission";
time: string; time: string;
// For messages: // For messages:
role?: "user" | "assistant"; role?: "user" | "assistant";
@ -15,4 +21,13 @@ export type TimelineEntry = {
reasoning?: { text: string; visibility?: string }; reasoning?: { text: string; visibility?: string };
// For meta: // For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" }; meta?: { title: string; detail?: string; severity?: "info" | "error" };
// For permission requests:
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
}; };

228
pnpm-lock.yaml generated
View file

@ -51,7 +51,7 @@ importers:
dependencies: dependencies:
'@cloudflare/sandbox': '@cloudflare/sandbox':
specifier: latest specifier: latest
version: 0.7.12 version: 0.7.12(@opencode-ai/sdk@1.2.24)
hono: hono:
specifier: ^4.12.2 specifier: ^4.12.2
version: 4.12.2 version: 4.12.2
@ -271,6 +271,25 @@ importers:
specifier: latest specifier: latest
version: 5.9.3 version: 5.9.3
examples/permissions:
dependencies:
commander:
specifier: ^12.1.0
version: 12.1.0
sandbox-agent:
specifier: workspace:*
version: link:../../sdks/typescript
devDependencies:
'@types/node':
specifier: latest
version: 25.4.0
tsx:
specifier: latest
version: 4.21.0
typescript:
specifier: latest
version: 5.9.3
examples/persist-memory: examples/persist-memory:
dependencies: dependencies:
'@sandbox-agent/example-shared': '@sandbox-agent/example-shared':
@ -439,7 +458,7 @@ importers:
'@iarna/toml': '@iarna/toml':
specifier: ^2.2.5 specifier: ^2.2.5
version: 2.2.5 version: 2.2.5
'@sandbox-agent/factory-shared': '@openhandoff/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
'@sandbox-agent/persist-rivet': '@sandbox-agent/persist-rivet':
@ -455,7 +474,7 @@ importers:
specifier: ^10.3.1 specifier: ^10.3.1
version: 10.3.1 version: 10.3.1
rivetkit: rivetkit:
specifier: ^2.1.6 specifier: 2.1.6
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260305.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0) version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260305.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0)
sandbox-agent: sandbox-agent:
specifier: workspace:* specifier: workspace:*
@ -479,11 +498,11 @@ importers:
factory/packages/client: factory/packages/client:
dependencies: dependencies:
'@sandbox-agent/factory-shared': '@openhandoff/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
rivetkit: rivetkit:
specifier: ^2.1.6 specifier: 2.1.6
version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260305.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0) version: 2.1.6(@hono/node-server@1.19.9(hono@4.12.2))(@hono/node-ws@1.3.0(@hono/node-server@1.19.9(hono@4.12.2))(hono@4.12.2))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260305.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.18.0)(better-sqlite3@11.10.0)(bun-types@1.3.10)(pg@8.20.0))(ws@8.19.0)
devDependencies: devDependencies:
tsup: tsup:
@ -492,13 +511,13 @@ importers:
factory/packages/frontend: factory/packages/frontend:
dependencies: dependencies:
'@sandbox-agent/factory-client': '@openhandoff/client':
specifier: workspace:* specifier: workspace:*
version: link:../client version: link:../client
'@sandbox-agent/factory-frontend-errors': '@openhandoff/frontend-errors':
specifier: workspace:* specifier: workspace:*
version: link:../frontend-errors version: link:../frontend-errors
'@sandbox-agent/factory-shared': '@openhandoff/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
'@tanstack/react-query': '@tanstack/react-query':
@ -537,7 +556,7 @@ importers:
version: 18.3.7(@types/react@18.3.27) version: 18.3.7(@types/react@18.3.27)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) version: 5.1.4(vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
react-grab: react-grab:
specifier: ^0.1.13 specifier: ^0.1.13
version: 0.1.27(@types/react@18.3.27)(react@19.2.4) version: 0.1.27(@types/react@18.3.27)(react@19.2.4)
@ -546,7 +565,7 @@ importers:
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
vite: vite:
specifier: ^7.1.3 specifier: ^7.1.3
version: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
factory/packages/frontend-errors: factory/packages/frontend-errors:
dependencies: dependencies:
@ -562,7 +581,7 @@ importers:
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
vite: vite:
specifier: ^7.1.3 specifier: ^7.1.3
version: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
factory/packages/shared: factory/packages/shared:
dependencies: dependencies:
@ -600,7 +619,7 @@ importers:
version: 18.3.7(@types/react@18.3.27) version: 18.3.7(@types/react@18.3.27)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.7.0(vite@5.4.21(@types/node@25.3.5)) version: 4.7.0(vite@5.4.21(@types/node@25.4.0))
fake-indexeddb: fake-indexeddb:
specifier: ^6.2.4 specifier: ^6.2.4
version: 6.2.5 version: 6.2.5
@ -612,25 +631,25 @@ importers:
version: 5.9.3 version: 5.9.3
vite: vite:
specifier: ^5.4.7 specifier: ^5.4.7
version: 5.4.21(@types/node@25.3.5) version: 5.4.21(@types/node@25.4.0)
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
frontend/packages/website: frontend/packages/website:
dependencies: dependencies:
'@astrojs/react': '@astrojs/react':
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.4.2(@types/node@25.3.5)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) version: 4.4.2(@types/node@25.4.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)
'@astrojs/sitemap': '@astrojs/sitemap':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.7.0 version: 3.7.0
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.2(astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) version: 6.0.2(astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
astro: astro:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) version: 5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
framer-motion: framer-motion:
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -772,7 +791,7 @@ importers:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
sdks/cli-shared: sdks/cli-shared:
devDependencies: devDependencies:
@ -820,7 +839,7 @@ importers:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
sdks/gigacode/platforms/darwin-arm64: {} sdks/gigacode/platforms/darwin-arm64: {}
@ -980,6 +999,22 @@ importers:
specifier: ^8.19.0 specifier: ^8.19.0
version: 8.19.0 version: 8.19.0
server/packages/sandbox-agent/tests/opencode-compat:
dependencies:
'@opencode-ai/sdk':
specifier: ^1.1.21
version: 1.2.24
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
packages: packages:
'@agentclientprotocol/sdk@0.14.1': '@agentclientprotocol/sdk@0.14.1':
@ -2559,6 +2594,9 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@opencode-ai/sdk@1.2.24':
resolution: {integrity: sha512-MQamFkRl4B/3d6oIRLNpkYR2fcwet1V/ffKyOKJXWjtP/CT9PDJMtLpu6olVHjXKQi8zMNltwuMhv1QsNtRlZg==}
'@opentelemetry/api-logs@0.207.0': '@opentelemetry/api-logs@0.207.0':
resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -3325,6 +3363,9 @@ packages:
'@types/node@25.3.5': '@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/node@25.4.0':
resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
'@types/pg@8.16.0': '@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
@ -6972,15 +7013,15 @@ snapshots:
dependencies: dependencies:
prismjs: 1.30.0 prismjs: 1.30.0
'@astrojs/react@4.4.2(@types/node@25.3.5)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': '@astrojs/react@4.4.2(@types/node@25.4.0)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)':
dependencies: dependencies:
'@types/react': 18.3.27 '@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27) '@types/react-dom': 18.3.7(@types/react@18.3.27)
'@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
ultrahtml: 1.6.0 ultrahtml: 1.6.0
vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- jiti - jiti
@ -7001,9 +7042,9 @@ snapshots:
stream-replace-string: 2.0.0 stream-replace-string: 2.0.0
zod: 3.25.76 zod: 3.25.76
'@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
astro: 5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro: 5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
autoprefixer: 10.4.23(postcss@8.5.6) autoprefixer: 10.4.23(postcss@8.5.6)
postcss: 8.5.6 postcss: 8.5.6
postcss-load-config: 4.0.2(postcss@8.5.6) postcss-load-config: 4.0.2(postcss@8.5.6)
@ -7796,10 +7837,12 @@ snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {} '@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/sandbox@0.7.12': '@cloudflare/sandbox@0.7.12(@opencode-ai/sdk@1.2.24)':
dependencies: dependencies:
'@cloudflare/containers': 0.1.1 '@cloudflare/containers': 0.1.1
aws4fetch: 1.0.20 aws4fetch: 1.0.20
optionalDependencies:
'@opencode-ai/sdk': 1.2.24
'@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)': '@cloudflare/unenv-preset@2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260301.1)':
dependencies: dependencies:
@ -8615,6 +8658,8 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.20.1 fastq: 1.20.1
'@opencode-ai/sdk@1.2.24': {}
'@opentelemetry/api-logs@0.207.0': '@opentelemetry/api-logs@0.207.0':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@ -9573,6 +9618,10 @@ snapshots:
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2
'@types/node@25.4.0':
dependencies:
undici-types: 7.18.2
'@types/pg@8.16.0': '@types/pg@8.16.0':
dependencies: dependencies:
'@types/node': 24.10.9 '@types/node': 24.10.9
@ -9637,7 +9686,7 @@ snapshots:
- bare-abort-controller - bare-abort-controller
- react-native-b4a - react-native-b4a
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.3.5))': '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.4.0))':
dependencies: dependencies:
'@babel/core': 7.28.6 '@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
@ -9645,7 +9694,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27 '@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 5.4.21(@types/node@25.3.5) vite: 5.4.21(@types/node@25.4.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -9661,7 +9710,19 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@ -9669,7 +9730,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-rc.3 '@rolldown/pluginutils': 1.0.0-rc.3
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 react-refresh: 0.18.0
vite: 7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vite: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -9705,6 +9766,14 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 5.4.21(@types/node@25.3.5) vite: 5.4.21(@types/node@25.3.5)
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.4.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21(@types/node@25.4.0)
'@vitest/pretty-format@3.2.4': '@vitest/pretty-format@3.2.4':
dependencies: dependencies:
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
@ -9795,7 +9864,7 @@ snapshots:
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
astro@5.16.15(@types/node@25.3.5)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): astro@5.16.15(@types/node@25.4.0)(aws4fetch@1.0.20)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2):
dependencies: dependencies:
'@astrojs/compiler': 2.13.0 '@astrojs/compiler': 2.13.0
'@astrojs/internal-helpers': 0.7.5 '@astrojs/internal-helpers': 0.7.5
@ -9852,8 +9921,8 @@ snapshots:
unist-util-visit: 5.1.0 unist-util-visit: 5.1.0
unstorage: 1.17.4(aws4fetch@1.0.20) unstorage: 1.17.4(aws4fetch@1.0.20)
vfile: 6.0.3 vfile: 6.0.3
vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) vitefu: 1.1.1(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))
xxhash-wasm: 1.1.0 xxhash-wasm: 1.1.0
yargs-parser: 21.1.1 yargs-parser: 21.1.1
yocto-spinner: 0.2.3 yocto-spinner: 0.2.3
@ -13456,6 +13525,27 @@ snapshots:
- tsx - tsx
- yaml - yaml
vite-node@3.2.4(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@5.4.21(@types/node@22.19.7): vite@5.4.21(@types/node@22.19.7):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5
@ -13483,6 +13573,15 @@ snapshots:
'@types/node': 25.3.5 '@types/node': 25.3.5
fsevents: 2.3.3 fsevents: 2.3.3
vite@5.4.21(@types/node@25.4.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.6
rollup: 4.56.0
optionalDependencies:
'@types/node': 25.4.0
fsevents: 2.3.3
vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -13528,7 +13627,22 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.2 yaml: 2.8.2
vite@7.3.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.56.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.4.0
fsevents: 2.3.3
jiti: 1.21.7
tsx: 4.21.0
yaml: 2.8.2
vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@ -13537,15 +13651,15 @@ snapshots:
rollup: 4.56.0 rollup: 4.56.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 25.3.5 '@types/node': 25.4.0
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 1.21.7 jiti: 1.21.7
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.2 yaml: 2.8.2
vitefu@1.1.1(vite@6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): vitefu@1.1.1(vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)):
optionalDependencies: optionalDependencies:
vite: 6.4.1(@types/node@25.3.5)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies: dependencies:
@ -13673,6 +13787,48 @@ snapshots:
- tsx - tsx
- yaml - yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.4.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 5.4.21(@types/node@25.4.0)
vite-node: 3.2.4(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 25.4.0
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vscode-languageserver-textdocument@1.0.12: {} vscode-languageserver-textdocument@1.0.12: {}
vscode-languageserver-types@3.17.5: {} vscode-languageserver-types@3.17.5: {}

View file

@ -11,3 +11,4 @@ packages:
- "scripts/release" - "scripts/release"
- "scripts/sandbox-testing" - "scripts/sandbox-testing"
- "examples/*" - "examples/*"
- "server/packages/sandbox-agent/tests/opencode-compat"

View file

@ -247,3 +247,13 @@ Update this file continuously during the migration.
- Outcome: Accepted (spec updated). - Outcome: Accepted (spec updated).
- Status: in_progress - Status: in_progress
- Files: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx` - Files: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx`
- Date: 2026-03-10
- Commit: uncommitted
- Author: Unassigned
- Implementing: ACP HTTP client transport reentrancy for human-in-the-loop requests
- Friction/issue: The TypeScript `acp-http-client` serialized the full lifetime of each POST on a single write queue. A long-running `session/prompt` request therefore blocked the client from POSTing a response to an agent-initiated `session/request_permission`, deadlocking permission approval flows.
- Attempted fix/workaround: Make the HTTP transport fire POSTs asynchronously after preserving outbound ordering at enqueue time, rather than waiting for the entire HTTP response before the next write can begin. Keep response bodies routed back into the readable stream so request promises still resolve normally.
- Outcome: Accepted and implemented in `acp-http-client`.
- Status: resolved
- Files: `sdks/acp-http-client/src/index.ts`, `sdks/acp-http-client/tests/smoke.test.ts`, `sdks/typescript/tests/integration.test.ts`

View file

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

View file

@ -378,31 +378,39 @@ class StreamableHttpAcpTransport {
}); });
const url = this.buildUrl(this.bootstrapQueryIfNeeded()); const url = this.buildUrl(this.bootstrapQueryIfNeeded());
const response = await this.fetcher(url, {
method: "POST",
headers,
body: JSON.stringify(message),
});
this.postedOnce = true; this.postedOnce = true;
if (!response.ok) {
throw new AcpHttpError(response.status, await readProblem(response), response);
}
this.ensureSseLoop(); this.ensureSseLoop();
void this.postMessage(url, headers, message);
}
if (response.status === 200) { private async postMessage(url: string, headers: Headers, message: AnyMessage): Promise<void> {
const text = await response.text(); try {
if (text.trim()) { const response = await this.fetcher(url, {
const envelope = JSON.parse(text) as AnyMessage; method: "POST",
this.pushInbound(envelope); 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 // 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. // may stall subsequent requests to the same origin.
await response.text().catch(() => {}); 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(); 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", "name": "@sandbox-agent/cli-shared",
"version": "0.3.0", "version": "0.3.1",
"description": "Shared helpers for sandbox-agent CLI and SDK", "description": "Shared helpers for sandbox-agent CLI and SDK",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "sandbox-agent", "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.", "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {

View file

@ -8,8 +8,12 @@ import {
type CancelNotification, type CancelNotification,
type NewSessionRequest, type NewSessionRequest,
type NewSessionResponse, type NewSessionResponse,
type PermissionOption,
type PermissionOptionKind,
type PromptRequest, type PromptRequest,
type PromptResponse, type PromptResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionConfigOption, type SessionConfigOption,
type SessionNotification, type SessionNotification,
type SessionModeState, type SessionModeState,
@ -142,9 +146,28 @@ export interface SessionSendOptions {
} }
export type SessionEventListener = (event: SessionEvent) => void; 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 ProcessLogListener = (entry: ProcessLogEntry) => void;
export type ProcessLogFollowQuery = Omit<ProcessLogsQuery, "follow">; 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 { export interface AgentQueryOptions {
config?: boolean; config?: boolean;
noCache?: 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 { export class Session {
private record: SessionRecord; private record: SessionRecord;
private readonly sandbox: SandboxAgent; private readonly sandbox: SandboxAgent;
@ -280,14 +319,14 @@ export class Session {
return this; return this;
} }
async send(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> { async rawSend(method: string, params: Record<string, unknown> = {}, options: SessionSendOptions = {}): Promise<unknown> {
const updated = await this.sandbox.sendSessionMethod(this.id, method, params, options); const updated = await this.sandbox.rawSendSessionMethod(this.id, method, params, options);
this.apply(updated.session.toRecord()); this.apply(updated.session.toRecord());
return updated.response; return updated.response;
} }
async prompt(prompt: PromptRequest["prompt"]): Promise<PromptResponse> { 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; return response as PromptResponse;
} }
@ -327,6 +366,18 @@ export class Session {
return this.sandbox.onSessionEvent(this.id, listener); 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 { toRecord(): SessionRecord {
return { ...this.record }; return { ...this.record };
} }
@ -355,6 +406,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection, direction: AcpEnvelopeDirection,
localSessionId: string | null, localSessionId: string | null,
) => void; ) => void;
private readonly onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
private constructor( private constructor(
agent: string, agent: string,
@ -366,11 +423,18 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection, direction: AcpEnvelopeDirection,
localSessionId: string | null, localSessionId: string | null,
) => void, ) => void,
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>,
) { ) {
this.agent = agent; this.agent = agent;
this.connectionId = connectionId; this.connectionId = connectionId;
this.acp = acp; this.acp = acp;
this.onObservedEnvelope = onObservedEnvelope; this.onObservedEnvelope = onObservedEnvelope;
this.onPermissionRequest = onPermissionRequest;
} }
static async create(options: { static async create(options: {
@ -386,6 +450,12 @@ export class LiveAcpConnection {
direction: AcpEnvelopeDirection, direction: AcpEnvelopeDirection,
localSessionId: string | null, localSessionId: string | null,
) => void; ) => void;
onPermissionRequest: (
connection: LiveAcpConnection,
localSessionId: string,
agentSessionId: string,
request: RequestPermissionRequest,
) => Promise<RequestPermissionResponse>;
}): Promise<LiveAcpConnection> { }): Promise<LiveAcpConnection> {
const connectionId = randomId(); const connectionId = randomId();
@ -400,6 +470,12 @@ export class LiveAcpConnection {
bootstrapQuery: { agent: options.agent }, bootstrapQuery: { agent: options.agent },
}, },
client: { client: {
requestPermission: async (request: RequestPermissionRequest) => {
if (!live) {
return cancelledPermissionResponse();
}
return live.handlePermissionRequest(request);
},
sessionUpdate: async (_notification: SessionNotification) => { sessionUpdate: async (_notification: SessionNotification) => {
// Session updates are observed via envelope persistence. // 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({ const initResult = await acp.initialize({
protocolVersion: PROTOCOL_VERSION, protocolVersion: PROTOCOL_VERSION,
@ -550,6 +632,23 @@ export class LiveAcpConnection {
this.lastAdapterExitAt = Date.now(); 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 { private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
const id = envelopeId(envelope); const id = envelopeId(envelope);
const method = envelopeMethod(envelope); const method = envelopeMethod(envelope);
@ -782,6 +881,8 @@ export class SandboxAgent {
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>(); private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
private readonly sessionHandles = new Map<string, Session>(); private readonly sessionHandles = new Map<string, Session>();
private readonly eventListeners = new Map<string, Set<SessionEventListener>>(); 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 nextSessionEventIndexBySession = new Map<string, number>();
private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>(); private readonly seedSessionEventIndexBySession = new Map<string, Promise<void>>();
@ -840,6 +941,11 @@ export class SandboxAgent {
this.disposed = true; this.disposed = true;
this.healthWaitAbortController.abort(createAbortError("SandboxAgent was disposed.")); 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()]; const connections = [...this.liveConnections.values()];
this.liveConnections.clear(); this.liveConnections.clear();
const pending = [...this.pendingLiveConnections.values()]; const pending = [...this.pendingLiveConnections.values()];
@ -984,6 +1090,8 @@ export class SandboxAgent {
} }
async destroySession(id: string): Promise<Session> { async destroySession(id: string): Promise<Session> {
this.cancelPendingPermissionsForSession(id);
try { try {
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true); await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
} catch { } catch {
@ -1100,7 +1208,26 @@ export class SandboxAgent {
async getSessionModes(sessionId: string): Promise<SessionModeState | null> { async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
const record = await this.requireSessionRecord(sessionId); 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( private async setSessionCategoryValue(
@ -1155,7 +1282,7 @@ export class SandboxAgent {
return updated; return updated;
} }
async sendSessionMethod( async rawSendSessionMethod(
sessionId: string, sessionId: string,
method: string, method: string,
params: Record<string, unknown>, 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> { async getHealth(): Promise<HealthResponse> {
return this.requestHealth(); return this.requestHealth();
} }
@ -1301,9 +1469,22 @@ export class SandboxAgent {
} }
async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> { async getAgent(agent: string, options?: AgentQueryOptions): Promise<AgentInfo> {
return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}`, { try {
query: toAgentQuery(options), 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> { async installAgent(agent: string, request: AgentInstallRequest = {}): Promise<AgentInstallResponse> {
@ -1551,6 +1732,8 @@ export class SandboxAgent {
onObservedEnvelope: (connection, envelope, direction, localSessionId) => { onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(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); const raced = this.liveConnections.get(agent);
@ -1753,6 +1936,69 @@ export class SandboxAgent {
return record; 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> { private async requestJson<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.requestRaw(method, path, { const response = await this.requestRaw(method, path, {
query: options.query, 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 QueryValue = string | number | boolean | null | undefined;
type RequestOptions = { type RequestOptions = {
@ -2166,6 +2420,26 @@ function cloneEnvelope(envelope: AnyMessage): AnyMessage {
return JSON.parse(JSON.stringify(envelope)) as 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> { function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
@ -2314,6 +2588,35 @@ function extractKnownModeIds(modes: SessionModeState | null | undefined): string
.filter((value): value is string => !!value); .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( function applyCurrentMode(
modes: SessionModeState | null | undefined, modes: SessionModeState | null | undefined,
currentModeId: string, currentModeId: string,
@ -2344,6 +2647,25 @@ function applyConfigOptionValue(
return updated; 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 { function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) { if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
return null; return null;
@ -2368,6 +2690,60 @@ function cloneModes(value: SessionModeState | null | undefined): SessionModeStat
return JSON.parse(JSON.stringify(value)) as SessionModeState; 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 { function isSessionConfigOption(value: unknown): value is SessionConfigOption {
return ( return (
isRecord(value) && isRecord(value) &&

View file

@ -4,6 +4,7 @@ export {
SandboxAgent, SandboxAgent,
SandboxAgentError, SandboxAgentError,
Session, Session,
UnsupportedPermissionReplyError,
UnsupportedSessionCategoryError, UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError, UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError, UnsupportedSessionValueError,
@ -28,6 +29,10 @@ export type {
SessionResumeOrCreateRequest, SessionResumeOrCreateRequest,
SessionSendOptions, SessionSendOptions,
SessionEventListener, SessionEventListener,
PermissionReply,
PermissionRequestListener,
SessionPermissionRequest,
SessionPermissionRequestOption,
} from "./client.ts"; } from "./client.ts";
export type { InspectorUrlOptions } from "./inspector.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; 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"); const { createInterface } = require("node:readline");
let nextSession = 0; let nextSession = 0;
let nextPermission = 0;
const pendingPermissions = new Map();
function emit(value) { function emit(value) {
process.stdout.write(JSON.stringify(value) + "\n"); process.stdout.write(JSON.stringify(value) + "\n");
@ -65,6 +67,38 @@ rl.on("line", (line) => {
const hasId = Object.prototype.hasOwnProperty.call(msg, "id"); const hasId = Object.prototype.hasOwnProperty.call(msg, "id");
const method = hasMethod ? msg.method : undefined; 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") { if (method === "session/prompt") {
const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : ""; const sessionId = typeof msg?.params?.sessionId === "string" ? msg.params.sessionId : "";
const text = firstText(msg?.params?.prompt); 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) { if (!hasMethod || !hasId) {
@ -117,6 +196,10 @@ rl.on("line", (line) => {
} }
if (method === "session/prompt") { if (method === "session/prompt") {
const text = firstText(msg?.params?.prompt);
if (text.includes("permission")) {
return;
}
emit({ emit({
jsonrpc: "2.0", jsonrpc: "2.0",
id: msg.id, id: msg.id,

View file

@ -512,10 +512,10 @@ describe("Integration: TypeScript SDK flat session API", () => {
const session = await sdk.createSession({ agent: "mock" }); 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.", "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.", "Use destroySession(sessionId) instead.",
); );
@ -625,6 +625,43 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose(); 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 () => { it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({ const sdk = await SandboxAgent.connect({
baseUrl, baseUrl,

View file

@ -9,7 +9,7 @@
*/ */
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Permission API", () => { describe("OpenCode-compatible Permission API", () => {

View file

@ -3,7 +3,7 @@
*/ */
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v1"; import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
describe("OpenCode-compatible Question API", () => { describe("OpenCode-compatible Question API", () => {