feat: add configuration for model, mode, and thought level (#205)

* feat: add configuration for model, mode, and thought level

* docs: document Claude effort-level filesystem config

* fix: prevent panic on empty modes/thoughtLevels in parse_agent_config

Use `.first()` with safe fallback instead of direct `[0]` index access,
which would panic if the Vec is empty and no default is set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden session lifecycle and align cli.mdx example with claude.json

- destroySession: wrap session/cancel RPC in try/catch so local cleanup
  always succeeds even when the agent is unreachable
- createSession/resumeOrCreateSession: clean up the remote session if
  post-creation config calls (setMode/setModel/setThoughtLevel) fail,
  preventing leaked orphan sessions
- cli.mdx: fix example output to match current claude.json (model name,
  model order, and populated modes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: harden session lifecycle and align config persistence logic

- resumeOrCreateSession: Remove destroy-on-error for the resume path. Config
  errors now propagate without destroying a pre-existing session. The destroy
  pattern remains in createSession (where the session is newly created and has
  no prior state to preserve).

- setSessionMode fallback: When session/set_mode returns -32601 and the
  fallback uses session/set_config_option, now keep modes.currentModeId
  in sync with the updated currentValue. Prevents stale cached state in
  getModes() when the fallback path is used.

- persistSessionStateFromMethod: Re-read the record from persistence instead
  of using a stale pre-await snapshot. Prevents race conditions where
  concurrent session/update events (processed by persistSessionStateFromEvent)
  are silently overwritten by optimistic updates.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: correct doc examples with valid Codex modes and update stable API list

- Replace invalid Codex mode values ("plan", "build") with valid ones
  ("auto", "full-access") in agent-sessions.mdx and sdk-overview.mdx
- Update CLAUDE.md stable method enumerations to include new session
  config methods (setSessionMode, setSessionModel, etc.)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add OpenAPI annotations for process endpoints and fix config persistence race

Add summary/description to all process management endpoint specs and the
not_found error type. Fix hydrateSessionConfigOptions to re-read from
persistence after the network call, and sync mode-category configOptions
on session/update current_mode_update events.

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:
Nathan Flurry 2026-03-06 00:24:32 -08:00 committed by GitHub
parent e7343e14bd
commit c91791f88d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1675 additions and 70 deletions

View file

@ -1,5 +1,6 @@
import {
AcpHttpClient,
AcpRpcError,
PROTOCOL_VERSION,
type AcpEnvelopeDirection,
type AnyMessage,
@ -9,8 +10,12 @@ import {
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type SessionConfigOption,
type SessionNotification,
type SessionModeState,
type SetSessionConfigOptionResponse,
type SetSessionConfigOptionRequest,
type SetSessionModeResponse,
type SetSessionModeRequest,
} from "acp-http-client";
import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn.ts";
@ -67,6 +72,9 @@ const DEFAULT_BASE_URL = "http://sandbox-agent";
const DEFAULT_REPLAY_MAX_EVENTS = 50;
const DEFAULT_REPLAY_MAX_CHARS = 12_000;
const EVENT_INDEX_SCAN_EVENTS_LIMIT = 500;
const SESSION_CANCEL_METHOD = "session/cancel";
const MANUAL_CANCEL_ERROR =
"Manual session/cancel calls are not allowed. Use destroySession(sessionId) instead.";
const HEALTH_WAIT_MIN_DELAY_MS = 500;
const HEALTH_WAIT_MAX_DELAY_MS = 15_000;
const HEALTH_WAIT_LOG_AFTER_MS = 5_000;
@ -109,12 +117,18 @@ export interface SessionCreateRequest {
id?: string;
agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
}
export interface SessionResumeOrCreateRequest {
id: string;
agent: string;
sessionInit?: Omit<NewSessionRequest, "_meta">;
model?: string;
mode?: string;
thoughtLevel?: string;
}
export interface SessionSendOptions {
@ -158,6 +172,64 @@ export class SandboxAgentError extends Error {
}
}
export class UnsupportedSessionCategoryError extends Error {
readonly sessionId: string;
readonly category: string;
readonly availableCategories: string[];
constructor(sessionId: string, category: string, availableCategories: string[]) {
super(
`Session '${sessionId}' does not support category '${category}'. Available categories: ${availableCategories.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionCategoryError";
this.sessionId = sessionId;
this.category = category;
this.availableCategories = availableCategories;
}
}
export class UnsupportedSessionValueError extends Error {
readonly sessionId: string;
readonly category: string;
readonly configId: string;
readonly requestedValue: string;
readonly allowedValues: string[];
constructor(
sessionId: string,
category: string,
configId: string,
requestedValue: string,
allowedValues: string[],
) {
super(
`Session '${sessionId}' does not support value '${requestedValue}' for category '${category}' (configId='${configId}'). Allowed values: ${allowedValues.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionValueError";
this.sessionId = sessionId;
this.category = category;
this.configId = configId;
this.requestedValue = requestedValue;
this.allowedValues = allowedValues;
}
}
export class UnsupportedSessionConfigOptionError extends Error {
readonly sessionId: string;
readonly configId: string;
readonly availableConfigIds: string[];
constructor(sessionId: string, configId: string, availableConfigIds: string[]) {
super(
`Session '${sessionId}' does not expose config option '${configId}'. Available configIds: ${availableConfigIds.join(", ") || "(none)"}`,
);
this.name = "UnsupportedSessionConfigOptionError";
this.sessionId = sessionId;
this.configId = configId;
this.availableConfigIds = availableConfigIds;
}
}
export class Session {
private record: SessionRecord;
private readonly sandbox: SandboxAgent;
@ -211,6 +283,38 @@ export class Session {
return response as PromptResponse;
}
async setMode(modeId: string): Promise<SetSessionModeResponse | void> {
const updated = await this.sandbox.setSessionMode(this.id, modeId);
this.apply(updated.session.toRecord());
return updated.response;
}
async setConfigOption(configId: string, value: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionConfigOption(this.id, configId, value);
this.apply(updated.session.toRecord());
return updated.response;
}
async setModel(model: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionModel(this.id, model);
this.apply(updated.session.toRecord());
return updated.response;
}
async setThoughtLevel(thoughtLevel: string): Promise<SetSessionConfigOptionResponse> {
const updated = await this.sandbox.setSessionThoughtLevel(this.id, thoughtLevel);
this.apply(updated.session.toRecord());
return updated.response;
}
async getConfigOptions(): Promise<SessionConfigOption[]> {
return this.sandbox.getSessionConfigOptions(this.id);
}
async getModes(): Promise<SessionModeState | null> {
return this.sandbox.getSessionModes(this.id);
}
onEvent(listener: SessionEventListener): () => void {
return this.sandbox.onSessionEvent(this.id, listener);
}
@ -623,12 +727,35 @@ export class SandboxAgent {
lastConnectionId: live.connectionId,
createdAt: nowMs(),
sessionInit,
configOptions: cloneConfigOptions(response.configOptions),
modes: cloneModes(response.modes),
};
await this.persist.updateSession(record);
this.nextSessionEventIndexBySession.set(record.id, 1);
live.bindSession(record.id, record.agentSessionId);
return this.upsertSessionHandle(record);
let session = this.upsertSessionHandle(record);
try {
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
if (request.thoughtLevel) {
session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
}
} catch (err) {
try {
await this.destroySession(session.id);
} catch {
// Best-effort cleanup
}
throw err;
}
return session;
}
async resumeSession(id: string): Promise<Session> {
@ -652,6 +779,8 @@ export class SandboxAgent {
agentSessionId: recreated.sessionId,
lastConnectionId: live.connectionId,
destroyedAt: undefined,
configOptions: cloneConfigOptions(recreated.configOptions),
modes: cloneModes(recreated.modes),
};
await this.persist.updateSession(updated);
@ -664,16 +793,28 @@ export class SandboxAgent {
async resumeOrCreateSession(request: SessionResumeOrCreateRequest): Promise<Session> {
const existing = await this.persist.getSession(request.id);
if (existing) {
return this.resumeSession(existing.id);
let session = await this.resumeSession(existing.id);
if (request.mode) {
session = (await this.setSessionMode(session.id, request.mode)).session;
}
if (request.model) {
session = (await this.setSessionModel(session.id, request.model)).session;
}
if (request.thoughtLevel) {
session = (await this.setSessionThoughtLevel(session.id, request.thoughtLevel)).session;
}
return session;
}
return this.createSession(request);
}
async destroySession(id: string): Promise<Session> {
const existing = await this.persist.getSession(id);
if (!existing) {
throw new Error(`session '${id}' not found`);
try {
await this.sendSessionMethodInternal(id, SESSION_CANCEL_METHOD, {}, {}, true);
} catch {
// Best-effort: agent may already be gone
}
const existing = await this.requireSessionRecord(id);
const updated: SessionRecord = {
...existing,
@ -684,12 +825,181 @@ export class SandboxAgent {
return this.upsertSessionHandle(updated);
}
async setSessionMode(
sessionId: string,
modeId: string,
): Promise<{ session: Session; response: SetSessionModeResponse | void }> {
const mode = modeId.trim();
if (!mode) {
throw new Error("setSessionMode requires a non-empty modeId");
}
const record = await this.requireSessionRecord(sessionId);
const knownModeIds = extractKnownModeIds(record.modes);
if (knownModeIds.length > 0 && !knownModeIds.includes(mode)) {
throw new UnsupportedSessionValueError(sessionId, "mode", "mode", mode, knownModeIds);
}
try {
return (await this.sendSessionMethodInternal(
sessionId,
"session/set_mode",
{ modeId: mode },
{},
false,
)) as { session: Session; response: SetSessionModeResponse | void };
} catch (error) {
if (!(error instanceof AcpRpcError) || error.code !== -32601) {
throw error;
}
return this.setSessionCategoryValue(sessionId, "mode", mode);
}
}
async setSessionConfigOption(
sessionId: string,
configId: string,
value: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
const resolvedConfigId = configId.trim();
if (!resolvedConfigId) {
throw new Error("setSessionConfigOption requires a non-empty configId");
}
const resolvedValue = value.trim();
if (!resolvedValue) {
throw new Error("setSessionConfigOption requires a non-empty value");
}
const options = await this.getSessionConfigOptions(sessionId);
const option = findConfigOptionById(options, resolvedConfigId);
if (!option) {
throw new UnsupportedSessionConfigOptionError(
sessionId,
resolvedConfigId,
options.map((item) => item.id),
);
}
const allowedValues = extractConfigValues(option);
if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
throw new UnsupportedSessionValueError(
sessionId,
option.category ?? "uncategorized",
option.id,
resolvedValue,
allowedValues,
);
}
return (await this.sendSessionMethodInternal(
sessionId,
"session/set_config_option",
{
configId: resolvedConfigId,
value: resolvedValue,
},
{},
false,
)) as { session: Session; response: SetSessionConfigOptionResponse };
}
async setSessionModel(
sessionId: string,
model: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
return this.setSessionCategoryValue(sessionId, "model", model);
}
async setSessionThoughtLevel(
sessionId: string,
thoughtLevel: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
return this.setSessionCategoryValue(sessionId, "thought_level", thoughtLevel);
}
async getSessionConfigOptions(sessionId: string): Promise<SessionConfigOption[]> {
const record = await this.requireSessionRecord(sessionId);
const hydrated = await this.hydrateSessionConfigOptions(record.id, record);
return cloneConfigOptions(hydrated.configOptions) ?? [];
}
async getSessionModes(sessionId: string): Promise<SessionModeState | null> {
const record = await this.requireSessionRecord(sessionId);
return cloneModes(record.modes);
}
private async setSessionCategoryValue(
sessionId: string,
category: string,
value: string,
): Promise<{ session: Session; response: SetSessionConfigOptionResponse }> {
const resolvedValue = value.trim();
if (!resolvedValue) {
throw new Error(`setSession${toTitleCase(category)} requires a non-empty value`);
}
const options = await this.getSessionConfigOptions(sessionId);
const option = findConfigOptionByCategory(options, category);
if (!option) {
const categories = uniqueCategories(options);
throw new UnsupportedSessionCategoryError(sessionId, category, categories);
}
const allowedValues = extractConfigValues(option);
if (allowedValues.length > 0 && !allowedValues.includes(resolvedValue)) {
throw new UnsupportedSessionValueError(
sessionId,
category,
option.id,
resolvedValue,
allowedValues,
);
}
return this.setSessionConfigOption(sessionId, option.id, resolvedValue);
}
private async hydrateSessionConfigOptions(sessionId: string, snapshot: SessionRecord): Promise<SessionRecord> {
if (snapshot.configOptions !== undefined) {
return snapshot;
}
const info = await this.getAgent(snapshot.agent, { config: true });
const configOptions = normalizeSessionConfigOptions(info.configOptions) ?? [];
// Re-read the record from persistence so we merge against the latest
// state, not a stale snapshot captured before the network await.
const record = await this.persist.getSession(sessionId);
if (!record) {
return { ...snapshot, configOptions };
}
const updated: SessionRecord = {
...record,
configOptions,
};
await this.persist.updateSession(updated);
return updated;
}
async sendSessionMethod(
sessionId: string,
method: string,
params: Record<string, unknown>,
options: SessionSendOptions = {},
): Promise<{ session: Session; response: unknown }> {
return this.sendSessionMethodInternal(sessionId, method, params, options, false);
}
private async sendSessionMethodInternal(
sessionId: string,
method: string,
params: Record<string, unknown>,
options: SessionSendOptions,
allowManagedCancel: boolean,
): Promise<{ session: Session; response: unknown }> {
if (method === SESSION_CANCEL_METHOD && !allowManagedCancel) {
throw new Error(MANUAL_CANCEL_ERROR);
}
const record = await this.persist.getSession(sessionId);
if (!record) {
throw new Error(`session '${sessionId}' not found`);
@ -699,10 +1009,11 @@ export class SandboxAgent {
if (!live.hasBoundSession(record.id, record.agentSessionId)) {
// The persisted session points at a stale connection; restore lazily.
const restored = await this.resumeSession(record.id);
return this.sendSessionMethod(restored.id, method, params, options);
return this.sendSessionMethodInternal(restored.id, method, params, options, allowManagedCancel);
}
const response = await live.sendSessionMethod(record.id, method, params, options);
await this.persistSessionStateFromMethod(record.id, method, params, response);
const refreshed = await this.requireSessionRecord(record.id);
return {
session: this.upsertSessionHandle(refreshed),
@ -710,6 +1021,83 @@ export class SandboxAgent {
};
}
private async persistSessionStateFromMethod(
sessionId: string,
method: string,
params: Record<string, unknown>,
response: unknown,
): Promise<void> {
// Re-read the record from persistence so we merge against the latest
// state, not a stale snapshot captured before the RPC await.
const record = await this.persist.getSession(sessionId);
if (!record) {
return;
}
if (method === "session/set_config_option") {
const configId = typeof params.configId === "string" ? params.configId : null;
const value = typeof params.value === "string" ? params.value : null;
const updates: Partial<SessionRecord> = {};
const serverConfigOptions = extractConfigOptionsFromSetResponse(response);
if (serverConfigOptions) {
updates.configOptions = cloneConfigOptions(serverConfigOptions);
} else if (record.configOptions && configId && value) {
// Server didn't return configOptions — optimistically update the
// cached currentValue so subsequent getConfigOptions() reflects the
// change without a round-trip.
const updated = applyConfigOptionValue(record.configOptions, configId, value);
if (updated) {
updates.configOptions = updated;
}
}
// When a mode-category config option is set via set_config_option
// (fallback path from setSessionMode), keep modes.currentModeId in sync.
if (configId && value) {
const source = updates.configOptions ?? record.configOptions;
const option = source ? findConfigOptionById(source, configId) : null;
if (option?.category === "mode") {
const nextModes = applyCurrentMode(record.modes, value);
if (nextModes) {
updates.modes = nextModes;
}
}
}
if (Object.keys(updates).length > 0) {
await this.persist.updateSession({ ...record, ...updates });
}
return;
}
if (method === "session/set_mode") {
const modeId = typeof params.modeId === "string" ? params.modeId : null;
if (!modeId) {
return;
}
const updates: Partial<SessionRecord> = {};
const nextModes = applyCurrentMode(record.modes, modeId);
if (nextModes) {
updates.modes = nextModes;
}
// Keep configOptions mode-category currentValue in sync with the new
// mode, mirroring the reverse sync in the set_config_option path above.
if (record.configOptions) {
const modeOption = findConfigOptionByCategory(record.configOptions, "mode");
if (modeOption) {
const updated = applyConfigOptionValue(record.configOptions, modeOption.id, modeId);
if (updated) {
updates.configOptions = updated;
}
}
}
if (Object.keys(updates).length > 0) {
await this.persist.updateSession({ ...record, ...updates });
}
}
}
onSessionEvent(sessionId: string, listener: SessionEventListener): () => void {
const listeners = this.eventListeners.get(sessionId) ?? new Set<SessionEventListener>();
listeners.add(listener);
@ -1024,6 +1412,7 @@ export class SandboxAgent {
};
await this.persist.insertEvent(event);
await this.persistSessionStateFromEvent(localSessionId, envelope, direction);
const listeners = this.eventListeners.get(localSessionId);
if (!listeners || listeners.size === 0) {
@ -1035,6 +1424,56 @@ export class SandboxAgent {
}
}
private async persistSessionStateFromEvent(
sessionId: string,
envelope: AnyMessage,
direction: AcpEnvelopeDirection,
): Promise<void> {
if (direction !== "inbound") {
return;
}
if (envelopeMethod(envelope) !== "session/update") {
return;
}
const update = envelopeSessionUpdate(envelope);
if (!update || typeof update.sessionUpdate !== "string") {
return;
}
const record = await this.persist.getSession(sessionId);
if (!record) {
return;
}
if (update.sessionUpdate === "config_option_update") {
const configOptions = normalizeSessionConfigOptions(update.configOptions);
if (configOptions) {
await this.persist.updateSession({
...record,
configOptions,
});
}
return;
}
if (update.sessionUpdate === "current_mode_update") {
const modeId = typeof update.currentModeId === "string" ? update.currentModeId : null;
if (!modeId) {
return;
}
const nextModes = applyCurrentMode(record.modes, modeId);
if (!nextModes) {
return;
}
await this.persist.updateSession({
...record,
modes: nextModes,
});
}
}
private async allocateSessionEventIndex(sessionId: string): Promise<number> {
await this.ensureSessionEventIndexSeeded(sessionId);
const nextIndex = this.nextSessionEventIndexBySession.get(sessionId) ?? 1;
@ -1543,6 +1982,145 @@ async function readProblem(response: Response): Promise<ProblemDetails | undefin
}
}
function normalizeSessionConfigOptions(value: unknown): SessionConfigOption[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized = value.filter(isSessionConfigOption) as SessionConfigOption[];
return cloneConfigOptions(normalized) ?? [];
}
function extractConfigOptionsFromSetResponse(response: unknown): SessionConfigOption[] | undefined {
if (!isRecord(response)) {
return undefined;
}
return normalizeSessionConfigOptions(response.configOptions);
}
function findConfigOptionByCategory(
options: SessionConfigOption[],
category: string,
): SessionConfigOption | undefined {
return options.find((option) => option.category === category);
}
function findConfigOptionById(
options: SessionConfigOption[],
configId: string,
): SessionConfigOption | undefined {
return options.find((option) => option.id === configId);
}
function uniqueCategories(options: SessionConfigOption[]): string[] {
return [...new Set(options.map((option) => option.category).filter((value): value is string => !!value))].sort();
}
function extractConfigValues(option: SessionConfigOption): string[] {
if (!isRecord(option) || option.type !== "select" || !Array.isArray(option.options)) {
return [];
}
const values: string[] = [];
for (const entry of option.options as unknown[]) {
if (isRecord(entry) && typeof entry.value === "string") {
values.push(entry.value);
continue;
}
if (isRecord(entry) && Array.isArray(entry.options)) {
for (const nested of entry.options) {
if (isRecord(nested) && typeof nested.value === "string") {
values.push(nested.value);
}
}
}
}
return [...new Set(values)];
}
function extractKnownModeIds(modes: SessionModeState | null | undefined): string[] {
if (!modes || !Array.isArray(modes.availableModes)) {
return [];
}
return modes.availableModes
.map((mode) => (typeof mode.id === "string" ? mode.id : null))
.filter((value): value is string => !!value);
}
function applyCurrentMode(
modes: SessionModeState | null | undefined,
currentModeId: string,
): SessionModeState | null {
if (modes && Array.isArray(modes.availableModes)) {
return {
...modes,
currentModeId,
};
}
return {
currentModeId,
availableModes: [],
};
}
function applyConfigOptionValue(
configOptions: SessionConfigOption[],
configId: string,
value: string,
): SessionConfigOption[] | null {
const idx = configOptions.findIndex((o) => o.id === configId);
if (idx === -1) {
return null;
}
const updated = cloneConfigOptions(configOptions) ?? [];
updated[idx] = { ...updated[idx]!, currentValue: value };
return updated;
}
function envelopeSessionUpdate(message: AnyMessage): Record<string, unknown> | null {
if (!isRecord(message) || !("params" in message) || !isRecord(message.params)) {
return null;
}
if (!("update" in message.params) || !isRecord(message.params.update)) {
return null;
}
return message.params.update;
}
function cloneConfigOptions(value: SessionConfigOption[] | null | undefined): SessionConfigOption[] | undefined {
if (!value) {
return undefined;
}
return JSON.parse(JSON.stringify(value)) as SessionConfigOption[];
}
function cloneModes(value: SessionModeState | null | undefined): SessionModeState | null {
if (!value) {
return null;
}
return JSON.parse(JSON.stringify(value)) as SessionModeState;
}
function isSessionConfigOption(value: unknown): value is SessionConfigOption {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.name === "string" &&
typeof value.type === "string"
);
}
function toTitleCase(input: string): string {
if (!input) {
return "";
}
return input
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join("");
}
function formatHealthWaitError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;

View file

@ -58,36 +58,105 @@ export interface paths {
get: operations["get_v1_health"];
};
"/v1/processes": {
/**
* List all managed processes.
* @description Returns a list of all processes (running and exited) currently tracked
* by the runtime, sorted by process ID.
*/
get: operations["get_v1_processes"];
/**
* Create a long-lived managed process.
* @description Spawns a new process with the given command and arguments. Supports both
* pipe-based and PTY (tty) modes. Returns the process descriptor on success.
*/
post: operations["post_v1_processes"];
};
"/v1/processes/config": {
/**
* Get process runtime configuration.
* @description Returns the current runtime configuration for the process management API,
* including limits for concurrency, timeouts, and buffer sizes.
*/
get: operations["get_v1_processes_config"];
/**
* Update process runtime configuration.
* @description Replaces the runtime configuration for the process management API.
* Validates that all values are non-zero and clamps default timeout to max.
*/
post: operations["post_v1_processes_config"];
};
"/v1/processes/run": {
/**
* Run a one-shot command.
* @description Executes a command to completion and returns its stdout, stderr, exit code,
* and duration. Supports configurable timeout and output size limits.
*/
post: operations["post_v1_processes_run"];
};
"/v1/processes/{id}": {
/**
* Get a single process by ID.
* @description Returns the current state of a managed process including its status,
* PID, exit code, and creation/exit timestamps.
*/
get: operations["get_v1_process"];
/**
* Delete a process record.
* @description Removes a stopped process from the runtime. Returns 409 if the process
* is still running; stop or kill it first.
*/
delete: operations["delete_v1_process"];
};
"/v1/processes/{id}/input": {
/**
* Write input to a process.
* @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
* Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
* payload exceeds the configured `maxInputBytesPerRequest` limit.
*/
post: operations["post_v1_process_input"];
};
"/v1/processes/{id}/kill": {
/**
* Send SIGKILL to a process.
* @description Sends SIGKILL to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post: operations["post_v1_process_kill"];
};
"/v1/processes/{id}/logs": {
/**
* Fetch process logs.
* @description Returns buffered log entries for a process. Supports filtering by stream
* type, tail count, and sequence-based resumption. When `follow=true`,
* returns an SSE stream that replays buffered entries then streams live output.
*/
get: operations["get_v1_process_logs"];
};
"/v1/processes/{id}/stop": {
/**
* Send SIGTERM to a process.
* @description Sends SIGTERM to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post: operations["post_v1_process_stop"];
};
"/v1/processes/{id}/terminal/resize": {
/**
* Resize a process terminal.
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
* sends SIGWINCH so the child process can adapt.
*/
post: operations["post_v1_process_terminal_resize"];
};
"/v1/processes/{id}/terminal/ws": {
/**
* Open an interactive WebSocket terminal session.
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
* `access_token` query param for browser-based auth (WebSocket API cannot
* send custom headers). Streams raw PTY output as binary frames and accepts
* JSON control frames for input, resize, and close.
*/
get: operations["get_v1_process_terminal_ws"];
};
}
@ -166,7 +235,7 @@ export interface components {
agents: components["schemas"]["AgentInfo"][];
};
/** @enum {string} */
ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
ErrorType: "invalid_request" | "conflict" | "unsupported_agent" | "agent_not_installed" | "install_failed" | "agent_process_exited" | "token_invalid" | "permission_denied" | "not_acceptable" | "unsupported_media_type" | "not_found" | "session_not_found" | "session_already_exists" | "mode_not_supported" | "stream_error" | "timeout";
FsActionResponse: {
path: string;
};
@ -891,6 +960,11 @@ export interface operations {
};
};
};
/**
* List all managed processes.
* @description Returns a list of all processes (running and exited) currently tracked
* by the runtime, sorted by process ID.
*/
get_v1_processes: {
responses: {
/** @description List processes */
@ -907,6 +981,11 @@ export interface operations {
};
};
};
/**
* Create a long-lived managed process.
* @description Spawns a new process with the given command and arguments. Supports both
* pipe-based and PTY (tty) modes. Returns the process descriptor on success.
*/
post_v1_processes: {
requestBody: {
content: {
@ -940,6 +1019,11 @@ export interface operations {
};
};
};
/**
* Get process runtime configuration.
* @description Returns the current runtime configuration for the process management API,
* including limits for concurrency, timeouts, and buffer sizes.
*/
get_v1_processes_config: {
responses: {
/** @description Current runtime process config */
@ -956,6 +1040,11 @@ export interface operations {
};
};
};
/**
* Update process runtime configuration.
* @description Replaces the runtime configuration for the process management API.
* Validates that all values are non-zero and clamps default timeout to max.
*/
post_v1_processes_config: {
requestBody: {
content: {
@ -983,6 +1072,11 @@ export interface operations {
};
};
};
/**
* Run a one-shot command.
* @description Executes a command to completion and returns its stdout, stderr, exit code,
* and duration. Supports configurable timeout and output size limits.
*/
post_v1_processes_run: {
requestBody: {
content: {
@ -1010,6 +1104,11 @@ export interface operations {
};
};
};
/**
* Get a single process by ID.
* @description Returns the current state of a managed process including its status,
* PID, exit code, and creation/exit timestamps.
*/
get_v1_process: {
parameters: {
path: {
@ -1038,6 +1137,11 @@ export interface operations {
};
};
};
/**
* Delete a process record.
* @description Removes a stopped process from the runtime. Returns 409 if the process
* is still running; stop or kill it first.
*/
delete_v1_process: {
parameters: {
path: {
@ -1070,6 +1174,12 @@ export interface operations {
};
};
};
/**
* Write input to a process.
* @description Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).
* Data can be encoded as base64, utf8, or text. Returns 413 if the decoded
* payload exceeds the configured `maxInputBytesPerRequest` limit.
*/
post_v1_process_input: {
parameters: {
path: {
@ -1115,6 +1225,11 @@ export interface operations {
};
};
};
/**
* Send SIGKILL to a process.
* @description Sends SIGKILL to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post_v1_process_kill: {
parameters: {
query?: {
@ -1147,6 +1262,12 @@ export interface operations {
};
};
};
/**
* Fetch process logs.
* @description Returns buffered log entries for a process. Supports filtering by stream
* type, tail count, and sequence-based resumption. When `follow=true`,
* returns an SSE stream that replays buffered entries then streams live output.
*/
get_v1_process_logs: {
parameters: {
query?: {
@ -1185,6 +1306,11 @@ export interface operations {
};
};
};
/**
* Send SIGTERM to a process.
* @description Sends SIGTERM to the process and optionally waits up to `waitMs`
* milliseconds for the process to exit before returning.
*/
post_v1_process_stop: {
parameters: {
query?: {
@ -1217,6 +1343,11 @@ export interface operations {
};
};
};
/**
* Resize a process terminal.
* @description Sets the PTY window size (columns and rows) for a tty-mode process and
* sends SIGWINCH so the child process can adapt.
*/
post_v1_process_terminal_resize: {
parameters: {
path: {
@ -1262,6 +1393,13 @@ export interface operations {
};
};
};
/**
* Open an interactive WebSocket terminal session.
* @description Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts
* `access_token` query param for browser-based auth (WebSocket API cannot
* send custom headers). Streams raw PTY output as binary frames and accepts
* JSON control frames for input, resize, and close.
*/
get_v1_process_terminal_ws: {
parameters: {
query?: {

View file

@ -3,6 +3,9 @@ export {
SandboxAgent,
SandboxAgentError,
Session,
UnsupportedSessionCategoryError,
UnsupportedSessionConfigOptionError,
UnsupportedSessionValueError,
} from "./client.ts";
export { AcpRpcError } from "acp-http-client";

View file

@ -1,4 +1,9 @@
import type { AnyMessage, NewSessionRequest } from "acp-http-client";
import type {
AnyMessage,
NewSessionRequest,
SessionConfigOption,
SessionModeState,
} from "acp-http-client";
import type { components, operations } from "./generated/openapi.ts";
export type ProblemDetails = components["schemas"]["ProblemDetails"];
@ -92,6 +97,8 @@ export interface SessionRecord {
createdAt: number;
destroyedAt?: number;
sessionInit?: Omit<NewSessionRequest, "_meta">;
configOptions?: SessionConfigOption[];
modes?: SessionModeState | null;
}
export type SessionEventSender = "client" | "agent";
@ -231,6 +238,12 @@ function cloneSessionRecord(session: SessionRecord): SessionRecord {
sessionInit: session.sessionInit
? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"])
: undefined,
configOptions: session.configOptions
? (JSON.parse(JSON.stringify(session.configOptions)) as SessionRecord["configOptions"])
: undefined,
modes: session.modes
? (JSON.parse(JSON.stringify(session.modes)) as SessionRecord["modes"])
: session.modes,
};
}

View file

@ -520,6 +520,127 @@ describe("Integration: TypeScript SDK flat session API", () => {
await sdk.dispose();
});
it("blocks manual session/cancel and requires destroySession", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await expect(session.send("session/cancel")).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
await expect(sdk.sendSessionMethod(session.id, "session/cancel", {})).rejects.toThrow(
"Use destroySession(sessionId) instead.",
);
const destroyed = await sdk.destroySession(session.id);
expect(destroyed.destroyedAt).toBeDefined();
const reloaded = await sdk.getSession(session.id);
expect(reloaded?.destroyedAt).toBeDefined();
await sdk.dispose();
});
it("supports typed config helpers and createSession preconfiguration", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({
agent: "mock",
model: "mock",
});
const options = await session.getConfigOptions();
expect(options.some((option) => option.category === "model")).toBe(true);
await expect(session.setModel("unknown-model")).rejects.toThrow("does not support value");
await sdk.dispose();
});
it("setModel happy path switches to a valid model", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setModel("mock-fast");
const options = await session.getConfigOptions();
const modelOption = options.find((o) => o.category === "model");
expect(modelOption?.currentValue).toBe("mock-fast");
await sdk.dispose();
});
it("setMode happy path switches to a valid mode", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setMode("plan");
const modes = await session.getModes();
expect(modes?.currentModeId).toBe("plan");
await sdk.dispose();
});
it("setThoughtLevel happy path switches to a valid thought level", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
await session.setThoughtLevel("high");
const options = await session.getConfigOptions();
const thoughtOption = options.find((o) => o.category === "thought_level");
expect(thoughtOption?.currentValue).toBe("high");
await sdk.dispose();
});
it("setModel/setMode/setThoughtLevel can be changed multiple times", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,
token,
});
const session = await sdk.createSession({ agent: "mock" });
// Model: mock → mock-fast → mock
await session.setModel("mock-fast");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock-fast");
await session.setModel("mock");
expect((await session.getConfigOptions()).find((o) => o.category === "model")?.currentValue).toBe("mock");
// Mode: normal → plan → normal
await session.setMode("plan");
expect((await session.getModes())?.currentModeId).toBe("plan");
await session.setMode("normal");
expect((await session.getModes())?.currentModeId).toBe("normal");
// Thought level: low → high → medium → low
await session.setThoughtLevel("high");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("high");
await session.setThoughtLevel("medium");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("medium");
await session.setThoughtLevel("low");
expect((await session.getConfigOptions()).find((o) => o.category === "thought_level")?.currentValue).toBe("low");
await sdk.dispose();
});
it("supports MCP and skills config HTTP helpers", async () => {
const sdk = await SandboxAgent.connect({
baseUrl,