Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/plans/2026-08-27-standalone-pr4-typescript-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Standalone PR4 TypeScript SDK Implementation Plan

## Scope

Build the TypeScript SDK surface for the `standalone_sessions_v1` daemon API introduced by PR #10179. This stage changes only `packages/sdk-typescript` plus this implementation plan. WebUI, WebShell, daemon routes, and standalone lifecycle semantics remain out of scope.

The implementation is stacked on PR #10179 at `fbd3bf32bd207424e39bb7063728807f873d1668`. Before publication against `main`, rebase onto the merged PR3 result and re-audit the final route contract.

## Public API

- Add narrow standalone session, restored session, summary, lookup, list, working-directory, metadata, batch, and creation-recovery types.
- Add capability-gated `DaemonClient` methods for create, list, exact lookup, load, resume, repair, rename, export, archive, unarchive, and delete.
- Let create accept an optional caller UUID; otherwise generate a UUID before the request. Never retry the create request.
- On a structured `standalone_creation_outcome_unknown` response, malformed successful response, or transport-level unknown outcome, perform one exact lookup and throw an error containing the generated UUID and the observed recovery state.
- Add `DaemonSessionClient` standalone create/load/resume factories and store an explicit restore strategy. Reattach workspace sessions by cwd and standalone sessions through the dedicated route.
- Runtime-validate every new JSON response before exposing it to consumers.

## Compatibility and failure behavior

- Every standalone method first requires `standalone_sessions_v1`; an old daemon fails before any standalone route is called.
- Standalone request types cannot express `workspaceCwd`, source, scope, branch, or worktree overrides.
- Exact lookup preserves the daemon's `202 creating`, `200 existing`, and `404 standalone_session_not_found` contract.
- A definite HTTP rejection remains a `DaemonHttpError`. Only an unknown create outcome is wrapped with recovery context.
- Browser code uses `globalThis.crypto.randomUUID()` and introduces no Node-only import.
- Existing workspace methods and the default workspace restore behavior remain source-compatible.

## Verification

- Request-shape tests for every route, including query encoding and client identity headers.
- Capability-absence tests proving no standalone request is sent.
- Create tests for generated and caller UUIDs, canonical response identity, structured outcome unknown, transport timeout, malformed success, and `202/200/404` lookup recovery.
- Runtime-validation tests for malformed sessions, summaries, working-directory results, metadata, and batch results.
- `DaemonSessionClient` tests for standalone create/load/resume and standalone versus workspace reattach.
- Public export type checks, TypeScript package tests, typecheck, lint, formatting, Node/browser builds, repository build, and repository typecheck.

## Audit decisions

- Keep HTTP ownership in `DaemonClient` and session-bound recovery in `DaemonSessionClient`; do not add another standalone client object.
- Keep validators in one standalone-specific leaf module to avoid expanding the already-large general daemon type file and to keep runtime checks reusable without UI dependencies.
- Perform exactly one automatic exact lookup after an unknown create result. Do not poll, load, resume, or issue a second create automatically; the caller retains control over further recovery.
- Do not cache capability results in PR4. Existing SDK capability checks are live probes, and adding cache invalidation would broaden this stage.
4 changes: 3 additions & 1 deletion packages/sdk-typescript/scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ const rootDir = join(__dirname, '..');
// main to 199KB merges within this headroom, so no further bump is needed.
// Bumped from 206KB to 208KB for transcript block change summaries used to
// avoid complete Web Shell projection on every streamed text update.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 208 * 1024;
// Bumped from 208KB to 215KB for the complete standalone-session lifecycle,
// response validation, and outcome-unknown recovery surface.
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 215 * 1024;
// The opt-in `daemon/transports` browser bundle legitimately ships the concrete
// ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so
// it's larger than the default barrel — but still budgeted so a future PR can't
Expand Down
288 changes: 288 additions & 0 deletions packages/sdk-typescript/src/daemon/DaemonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,35 @@ import type {
DaemonUnarchiveSessionsResult,
} from './types.js';
import { parseSseStream } from './sse.js';
import {
DaemonStandaloneCreationOutcomeUnknownError,
STANDALONE_SESSIONS_CAPABILITY,
isStandaloneCreationOutcomeUnknown,
isStandaloneSessionNotFoundError,
parseArchiveStandaloneSessionsResult,
parseDeleteStandaloneSessionsResult,
parseRestoredStandaloneSession,
parseStandaloneDirectoryResult,
parseStandaloneListPage,
parseStandaloneLookup,
parseStandaloneMetadataResult,
parseStandaloneSession,
parseUnarchiveStandaloneSessionsResult,
type CreateStandaloneSessionOptions,
type DaemonArchiveStandaloneSessionsResult,
type DaemonDeleteStandaloneSessionsResult,
type DaemonRestoredStandaloneSession,
type DaemonStandaloneCreationRecovery,
type DaemonStandaloneDirectoryResult,
type DaemonStandaloneMetadataResult,
type DaemonStandaloneSession,
type DaemonStandaloneSessionListOptions,
type DaemonStandaloneSessionListPage,
type DaemonStandaloneSessionLookup,
type DaemonStandaloneSessionSummary,
type DaemonUnarchiveStandaloneSessionsResult,
type RestoreStandaloneSessionRequest,
} from './standalone-sessions.js';

const WORKSPACE_MEMORY_REMEMBER_PATH = '/workspace/memory/remember';
const WORKSPACE_MEMORY_FORGET_PATH = '/workspace/memory/forget';
Expand Down Expand Up @@ -2525,6 +2554,265 @@ export class DaemonClient {

// -- Sessions ----------------------------------------------------------

async createStandaloneSession(
options: CreateStandaloneSessionOptions = {},
): Promise<DaemonStandaloneSession> {
await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY);
const { sessionId: requestedSessionId, ...request } = options;
const sessionId = (
requestedSessionId ?? globalThis.crypto.randomUUID()
).toLowerCase();
try {
const response = await this.jsonRequest<unknown>(
'/standalone/sessions',
'POST /standalone/sessions',
{
method: 'POST',
body: { sessionId, ...request },
mode: 'rest',
},
);
return parseStandaloneSession(
response,
'POST /standalone/sessions',
sessionId,
);
} catch (error) {
if (
error instanceof DaemonHttpError &&
!isStandaloneCreationOutcomeUnknown(error)
) {
throw error;
}
const recovery = await this.recoverStandaloneCreation(sessionId);
throw new DaemonStandaloneCreationOutcomeUnknownError(
sessionId,
recovery,
error,
);
}
}

async listStandaloneSessions(
options: DaemonStandaloneSessionListOptions = {},
): Promise<DaemonStandaloneSessionSummary[]> {
return (await this.listStandaloneSessionsPage(options)).sessions;
}

async listStandaloneSessionsPage(
options: DaemonStandaloneSessionListOptions = {},
): Promise<DaemonStandaloneSessionListPage> {
const query = new URLSearchParams();
if (options.cursor !== undefined) query.set('cursor', options.cursor);
Comment thread
doudouOUC marked this conversation as resolved.
if (options.pageSize !== undefined) {
query.set('size', String(options.pageSize));
}
Comment thread
doudouOUC marked this conversation as resolved.
if (options.archiveState !== undefined) {
query.set('archiveState', options.archiveState);
}
Comment thread
doudouOUC marked this conversation as resolved.
const queryString = query.toString();
const suffix = queryString ? `?${queryString}` : '';
return await this.standaloneJsonRequest(
`/standalone/sessions${suffix}`,
'GET /standalone/sessions',
parseStandaloneListPage,
);
}

async getStandaloneSession(
sessionId: string,
): Promise<DaemonStandaloneSessionLookup> {
await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY);
Comment thread
doudouOUC marked this conversation as resolved.
return await this.getStandaloneSessionUnchecked(sessionId.toLowerCase());
}

async loadStandaloneSession(
sessionId: string,
request: RestoreStandaloneSessionRequest = {},
clientId?: string,
): Promise<DaemonRestoredStandaloneSession> {
return await this.restoreStandaloneSession(
'load',
sessionId,
request,
clientId,
);
}

async resumeStandaloneSession(
sessionId: string,
request: RestoreStandaloneSessionRequest = {},
clientId?: string,
): Promise<DaemonRestoredStandaloneSession> {
return await this.restoreStandaloneSession(
'resume',
sessionId,
request,
clientId,
);
}

async repairStandaloneSessionDirectory(
sessionId: string,
): Promise<DaemonStandaloneDirectoryResult> {
const normalized = sessionId.toLowerCase();
const route = 'POST /standalone/sessions/:id/repair-directory';
return await this.standaloneJsonRequest(
`/standalone/sessions/${urlEncode(normalized)}/repair-directory`,
route,
(response) => parseStandaloneDirectoryResult(response, route, normalized),
{ method: 'POST', body: {} },
);
}

async renameStandaloneSession(
sessionId: string,
displayName: string,
clientId?: string,
): Promise<DaemonStandaloneMetadataResult> {
const normalized = sessionId.toLowerCase();
const route = 'PATCH /standalone/sessions/:id/metadata';
return await this.standaloneJsonRequest(
`/standalone/sessions/${urlEncode(normalized)}/metadata`,
route,
(response) => parseStandaloneMetadataResult(response, route, normalized),
{
method: 'PATCH',
body: { displayName },
clientId,
},
);
}

async exportStandaloneSession(
sessionId: string,
options: { format?: DaemonSessionExportFormat } = {},
): Promise<DaemonSessionExportResult> {
await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY);
return await this.sessionExportRequest(
`/standalone/sessions/${urlEncode(sessionId.toLowerCase())}/export`,
'GET /standalone/sessions/:id/export',
options,
);
}

async archiveStandaloneSessions(
sessionIds: string[],
): Promise<DaemonArchiveStandaloneSessionsResult> {
return await this.standaloneBatchRequest(
'archive',
sessionIds,
parseArchiveStandaloneSessionsResult,
);
}

async unarchiveStandaloneSessions(
sessionIds: string[],
): Promise<DaemonUnarchiveStandaloneSessionsResult> {
return await this.standaloneBatchRequest(
'unarchive',
sessionIds,
parseUnarchiveStandaloneSessionsResult,
);
}

async deleteStandaloneSessions(
sessionIds: string[],
): Promise<DaemonDeleteStandaloneSessionsResult> {
return await this.standaloneBatchRequest(
'delete',
sessionIds,
parseDeleteStandaloneSessionsResult,
);
}

private async standaloneJsonRequest<T>(
path: string,
route: string,
parse: (value: unknown, route: string) => T,
options: {
method?: string;
body?: unknown;
clientId?: string;
timeoutMs?: number;
} = {},
): Promise<T> {
await this.requireCapability(STANDALONE_SESSIONS_CAPABILITY);
const response = await this.jsonRequest<unknown>(path, route, {
...options,
mode: 'rest',
});
return parse(response, route);
}

private async standaloneBatchRequest<T>(
action: 'archive' | 'unarchive' | 'delete',
sessionIds: string[],
parse: (value: unknown, route: string) => T,
): Promise<T> {
const route = `POST /standalone/sessions/${action}`;
return await this.standaloneJsonRequest(
`/standalone/sessions/${action}`,
route,
parse,
{
method: 'POST',
body: {
sessionIds: sessionIds.map((sessionId) => sessionId.toLowerCase()),
},
},
);
}

private async restoreStandaloneSession(
action: 'load' | 'resume',
sessionId: string,
request: RestoreStandaloneSessionRequest,
clientId?: string,
): Promise<DaemonRestoredStandaloneSession> {
const normalized = sessionId.toLowerCase();
const route = `POST /standalone/sessions/:id/${action}`;
const { timeoutMs, ...body } = request;
return await this.standaloneJsonRequest(
`/standalone/sessions/${urlEncode(normalized)}/${action}`,
route,
(response) => parseRestoredStandaloneSession(response, route, normalized),
{
method: 'POST',
body,
clientId,
timeoutMs: this.resolveRestoreTimeoutMs(timeoutMs),
Comment thread
doudouOUC marked this conversation as resolved.
},
);
}

private async getStandaloneSessionUnchecked(
sessionId: string,
): Promise<DaemonStandaloneSessionLookup> {
const route = 'GET /standalone/sessions/:id';
const response = await this.jsonRequest<unknown>(
`/standalone/sessions/${urlEncode(sessionId)}`,
route,
{ mode: 'rest' },
);
return parseStandaloneLookup(response, route, sessionId);
}

private async recoverStandaloneCreation(
sessionId: string,
): Promise<DaemonStandaloneCreationRecovery> {
try {
const lookup = await this.getStandaloneSessionUnchecked(sessionId);
return 'state' in lookup
? { state: 'creating', sessionId }
: { state: 'existing', session: lookup };
} catch (error) {
return isStandaloneSessionNotFoundError(error)
? { state: 'absent', sessionId }
: { state: 'unknown', sessionId, error };
Comment thread
doudouOUC marked this conversation as resolved.
}
}

async createOrAttachSession(
req: CreateSessionRequest,
clientId?: string,
Expand Down
Loading
Loading