mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
Add ACP permission mode support to the SDK (#224)
* chore: recover hamburg workspace state * chore: drop workspace context files * refactor: generalize permissions example * refactor: parse permissions example flags * docs: clarify why fs and terminal stay native * feat: add interactive permission prompt UI to Inspector Add permission request handling to the Inspector UI so users can Allow, Always Allow, or Reject tool calls that require permissions instead of having them auto-cancelled. Wires up SDK onPermissionRequest/respondPermission through App → ChatPanel → ChatMessages with proper toolCallId-to-pendingId mapping. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent permission reply from silently escalating "once" to "always" Remove allow_always from the fallback chain when the user replies "once", aligning with the ACP spec which says "map by option kind first" with no fallback for allow_once. Also fix Inspector to use rawSend, revert hydration guard to accept empty configOptions, and handle respondPermission errors by rejecting the pending promise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5d65013aa5
commit
76586f409f
35 changed files with 1786 additions and 472 deletions
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(...)`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
18
examples/permissions/package.json
Normal file
18
examples/permissions/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
192
examples/permissions/src/index.ts
Normal file
192
examples/permissions/src/index.ts
Normal 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;
|
||||||
|
}>();
|
||||||
|
}
|
||||||
14
examples/permissions/tsconfig.json
Normal file
14
examples/permissions/tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -81,11 +81,12 @@ 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. Do not use published RivetKit npm packages.
|
4. The default dependency source for RivetKit is the published `rivetkit` package so workspace installs and CI remain self-contained.
|
||||||
5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace.
|
5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package.
|
||||||
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
|
- Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout`
|
||||||
- Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`.
|
- Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
|
||||||
6. Before using, build RivetKit in the rivet repo:
|
- Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit workspace when using the local checkout.
|
||||||
|
6. Before using a local checkout, build RivetKit in the rivet repo:
|
||||||
```bash
|
```bash
|
||||||
cd ../rivet-checkout/rivetkit-typescript
|
cd ../rivet-checkout/rivetkit-typescript
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
|
||||||
|
|
@ -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": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openhandoff/shared": "workspace:*",
|
"@openhandoff/shared": "workspace:*",
|
||||||
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
|
"rivetkit": "2.1.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.5.0"
|
"tsup": "^8.5.0"
|
||||||
|
|
|
||||||
|
|
@ -1595,6 +1595,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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
577
pnpm-lock.yaml
generated
577
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,3 +11,4 @@ packages:
|
||||||
- "scripts/release"
|
- "scripts/release"
|
||||||
- "scripts/sandbox-testing"
|
- "scripts/sandbox-testing"
|
||||||
- "examples/*"
|
- "examples/*"
|
||||||
|
- "server/packages/sandbox-agent/tests/opencode-compat"
|
||||||
|
|
|
||||||
|
|
@ -247,3 +247,13 @@ Update this file continuously during the migration.
|
||||||
- Owner: Unassigned.
|
- Owner: Unassigned.
|
||||||
- Status: in_progress
|
- Status: in_progress
|
||||||
- Links: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx`
|
- Links: `research/acp/simplify-server.md`, `docs/mcp-config.mdx`, `docs/skills-config.mdx`
|
||||||
|
|
||||||
|
- Date: 2026-03-10
|
||||||
|
- Area: ACP HTTP client transport reentrancy for human-in-the-loop requests
|
||||||
|
- 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.
|
||||||
|
- Impact: Permission requests arrived over SSE, but replying to them never resumed the original prompt turn. This blocked Claude and any other ACP agent using `session/request_permission`.
|
||||||
|
- Proposed direction: 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.
|
||||||
|
- Decision: Accepted and implemented in `acp-http-client`.
|
||||||
|
- Owner: Unassigned.
|
||||||
|
- Status: resolved
|
||||||
|
- Links: `sdks/acp-http-client/src/index.ts`, `sdks/acp-http-client/tests/smoke.test.ts`, `sdks/typescript/tests/integration.test.ts`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) &&
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue