init
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
|
||||
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import type { DirectConnectConfig } from './directConnectManager.js'
|
||||
import { connectResponseSchema } from './types.js'
|
||||
|
||||
/**
|
||||
* Errors thrown by createDirectConnectSession when the connection fails.
|
||||
*/
|
||||
export class DirectConnectError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'DirectConnectError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on a direct-connect server.
|
||||
*
|
||||
* Posts to `${serverUrl}/sessions`, validates the response, and returns
|
||||
* a DirectConnectConfig ready for use by the REPL or headless runner.
|
||||
*
|
||||
* Throws DirectConnectError on network, HTTP, or response-parsing failures.
|
||||
*/
|
||||
export async function createDirectConnectSession({
|
||||
serverUrl,
|
||||
authToken,
|
||||
cwd,
|
||||
dangerouslySkipPermissions,
|
||||
}: {
|
||||
serverUrl: string
|
||||
authToken?: string
|
||||
cwd: string
|
||||
dangerouslySkipPermissions?: boolean
|
||||
}): Promise<{
|
||||
config: DirectConnectConfig
|
||||
workDir?: string
|
||||
}> {
|
||||
const headers: Record<string, string> = {
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
if (authToken) {
|
||||
headers['authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(`${serverUrl}/sessions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: jsonStringify({
|
||||
cwd,
|
||||
...(dangerouslySkipPermissions && {
|
||||
dangerously_skip_permissions: true,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new DirectConnectError(
|
||||
`Failed to connect to server at ${serverUrl}: ${errorMessage(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new DirectConnectError(
|
||||
`Failed to create session: ${resp.status} ${resp.statusText}`,
|
||||
)
|
||||
}
|
||||
|
||||
const result = connectResponseSchema().safeParse(await resp.json())
|
||||
if (!result.success) {
|
||||
throw new DirectConnectError(
|
||||
`Invalid session response: ${result.error.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
const data = result.data
|
||||
return {
|
||||
config: {
|
||||
serverUrl,
|
||||
sessionId: data.session_id,
|
||||
wsUrl: data.ws_url,
|
||||
authToken,
|
||||
},
|
||||
workDir: data.work_dir,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/* eslint-disable eslint-plugin-n/no-unsupported-features/node-builtins */
|
||||
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
SDKControlPermissionRequest,
|
||||
StdoutMessage,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import type { RemoteMessageContent } from '../utils/teleport/api.js'
|
||||
|
||||
export type DirectConnectConfig = {
|
||||
serverUrl: string
|
||||
sessionId: string
|
||||
wsUrl: string
|
||||
authToken?: string
|
||||
}
|
||||
|
||||
export type DirectConnectCallbacks = {
|
||||
onMessage: (message: SDKMessage) => void
|
||||
onPermissionRequest: (
|
||||
request: SDKControlPermissionRequest,
|
||||
requestId: string,
|
||||
) => void
|
||||
onConnected?: () => void
|
||||
onDisconnected?: () => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
function isStdoutMessage(value: unknown): value is StdoutMessage {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'type' in value &&
|
||||
typeof value.type === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
export class DirectConnectSessionManager {
|
||||
private ws: WebSocket | null = null
|
||||
private config: DirectConnectConfig
|
||||
private callbacks: DirectConnectCallbacks
|
||||
|
||||
constructor(config: DirectConnectConfig, callbacks: DirectConnectCallbacks) {
|
||||
this.config = config
|
||||
this.callbacks = callbacks
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
const headers: Record<string, string> = {}
|
||||
if (this.config.authToken) {
|
||||
headers['authorization'] = `Bearer ${this.config.authToken}`
|
||||
}
|
||||
// Bun's WebSocket supports headers option but the DOM typings don't
|
||||
this.ws = new WebSocket(this.config.wsUrl, {
|
||||
headers,
|
||||
} as unknown as string[])
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.callbacks.onConnected?.()
|
||||
})
|
||||
|
||||
this.ws.addEventListener('message', event => {
|
||||
const data = typeof event.data === 'string' ? event.data : ''
|
||||
const lines = data.split('\n').filter((l: string) => l.trim())
|
||||
|
||||
for (const line of lines) {
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = jsonParse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isStdoutMessage(raw)) {
|
||||
continue
|
||||
}
|
||||
const parsed = raw
|
||||
|
||||
// Handle control requests (permission requests)
|
||||
if (parsed.type === 'control_request') {
|
||||
if (parsed.request.subtype === 'can_use_tool') {
|
||||
this.callbacks.onPermissionRequest(
|
||||
parsed.request,
|
||||
parsed.request_id,
|
||||
)
|
||||
} else {
|
||||
// Send an error response for unrecognized subtypes so the
|
||||
// server doesn't hang waiting for a reply that never comes.
|
||||
logForDebugging(
|
||||
`[DirectConnect] Unsupported control request subtype: ${parsed.request.subtype}`,
|
||||
)
|
||||
this.sendErrorResponse(
|
||||
parsed.request_id,
|
||||
`Unsupported control request subtype: ${parsed.request.subtype}`,
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Forward SDK messages (assistant, result, system, etc.)
|
||||
if (
|
||||
parsed.type !== 'control_response' &&
|
||||
parsed.type !== 'keep_alive' &&
|
||||
parsed.type !== 'control_cancel_request' &&
|
||||
parsed.type !== 'streamlined_text' &&
|
||||
parsed.type !== 'streamlined_tool_use_summary' &&
|
||||
!(parsed.type === 'system' && parsed.subtype === 'post_turn_summary')
|
||||
) {
|
||||
this.callbacks.onMessage(parsed)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
this.callbacks.onDisconnected?.()
|
||||
})
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
this.callbacks.onError?.(new Error('WebSocket connection error'))
|
||||
})
|
||||
}
|
||||
|
||||
sendMessage(content: RemoteMessageContent): boolean {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must match SDKUserMessage format expected by `--input-format stream-json`
|
||||
const message = jsonStringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: content,
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
})
|
||||
this.ws.send(message)
|
||||
return true
|
||||
}
|
||||
|
||||
respondToPermissionRequest(
|
||||
requestId: string,
|
||||
result: RemotePermissionResponse,
|
||||
): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
// Must match SDKControlResponse format expected by StructuredIO
|
||||
const response = jsonStringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: {
|
||||
behavior: result.behavior,
|
||||
...(result.behavior === 'allow'
|
||||
? { updatedInput: result.updatedInput }
|
||||
: { message: result.message }),
|
||||
},
|
||||
},
|
||||
})
|
||||
this.ws.send(response)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an interrupt signal to cancel the current request
|
||||
*/
|
||||
sendInterrupt(): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
// Must match SDKControlRequest format expected by StructuredIO
|
||||
const request = jsonStringify({
|
||||
type: 'control_request',
|
||||
request_id: crypto.randomUUID(),
|
||||
request: {
|
||||
subtype: 'interrupt',
|
||||
},
|
||||
})
|
||||
this.ws.send(request)
|
||||
}
|
||||
|
||||
private sendErrorResponse(requestId: string, error: string): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
const response = jsonStringify({
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id: requestId,
|
||||
error,
|
||||
},
|
||||
})
|
||||
this.ws.send(response)
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ChildProcess } from 'child_process'
|
||||
import { z } from 'zod/v4'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
|
||||
export const connectResponseSchema = lazySchema(() =>
|
||||
z.object({
|
||||
session_id: z.string(),
|
||||
ws_url: z.string(),
|
||||
work_dir: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
export type ServerConfig = {
|
||||
port: number
|
||||
host: string
|
||||
authToken: string
|
||||
unix?: string
|
||||
/** Idle timeout for detached sessions (ms). 0 = never expire. */
|
||||
idleTimeoutMs?: number
|
||||
/** Maximum number of concurrent sessions. */
|
||||
maxSessions?: number
|
||||
/** Default workspace directory for sessions that don't specify cwd. */
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
export type SessionState =
|
||||
| 'starting'
|
||||
| 'running'
|
||||
| 'detached'
|
||||
| 'stopping'
|
||||
| 'stopped'
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
status: SessionState
|
||||
createdAt: number
|
||||
workDir: string
|
||||
process: ChildProcess | null
|
||||
sessionKey?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable session key → session metadata. Persisted to ~/.claude/server-sessions.json
|
||||
* so sessions can be resumed across server restarts.
|
||||
*/
|
||||
export type SessionIndexEntry = {
|
||||
/** Server-assigned session ID (matches the subprocess's claude session). */
|
||||
sessionId: string
|
||||
/** The claude transcript session ID for --resume. Same as sessionId for direct sessions. */
|
||||
transcriptSessionId: string
|
||||
cwd: string
|
||||
permissionMode?: string
|
||||
createdAt: number
|
||||
lastActiveAt: number
|
||||
}
|
||||
|
||||
export type SessionIndex = Record<string, SessionIndexEntry>
|
||||
Reference in New Issue
Block a user