@castari/sdk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import type { QueryConfig, WSInputMessage, WSOutputMessage } from './types';
2
+ export * from './types';
3
+ type DaytonaCreateOptions = {
4
+ snapshot?: string;
5
+ image?: unknown;
6
+ resources?: {
7
+ cpu?: number;
8
+ memory?: number;
9
+ disk?: number;
10
+ };
11
+ autoStopInterval?: number;
12
+ autoArchiveInterval?: number;
13
+ autoDeleteInterval?: number;
14
+ ephemeral?: boolean;
15
+ public?: boolean;
16
+ volumes?: {
17
+ volumeId: string;
18
+ mountPath: string;
19
+ }[];
20
+ };
21
+ /**
22
+ * Configuration options for the Castari Client.
23
+ */
24
+ export interface ClientOptions extends Partial<QueryConfig> {
25
+ /** Local/custom connection URL (e.g., 'http://localhost:3000'). If omitted, Daytona mode is used. */
26
+ connectionUrl?: string;
27
+ /** Anthropic API key (required unless present in process.env.ANTHROPIC_API_KEY) */
28
+ anthropicApiKey?: string;
29
+ /** Enable debug logging */
30
+ debug?: boolean;
31
+ daytonaApiKey?: string;
32
+ daytonaApiUrl?: string;
33
+ daytonaTarget?: string;
34
+ /** Override to skip preview discovery; should be base URL to server (e.g., https://3000-<sandboxId>.<domain>) */
35
+ daytonaConnectionUrl?: string;
36
+ snapshot?: string;
37
+ image?: unknown;
38
+ /** Optional override for preview domain if provider metadata is missing. Example: "preview.daytona.io" */
39
+ daytonaPreviewDomain?: string;
40
+ resources?: DaytonaCreateOptions['resources'];
41
+ autoStopInterval?: number;
42
+ autoArchiveInterval?: number;
43
+ autoDeleteInterval?: number;
44
+ ephemeral?: boolean;
45
+ /** Optional Daytona volume name to mount at /home/daytona/agent-workspace. Defaults to undefined (no volume). */
46
+ volume?: string;
47
+ /** @deprecated Use `volume` instead */
48
+ workspaceVolumeName?: string;
49
+ }
50
+ export declare class CastariClient {
51
+ private ws?;
52
+ private options;
53
+ private messageHandlers;
54
+ private sandbox?;
55
+ private daytona?;
56
+ constructor(options?: ClientOptions);
57
+ start(): Promise<void>;
58
+ private setupLocalConnection;
59
+ private setupDaytonaConnection;
60
+ private handleMessage;
61
+ onMessage(handler: (message: WSOutputMessage) => void): () => void;
62
+ send(message: WSInputMessage): void;
63
+ stop(): Promise<void>;
64
+ }
package/dist/client.js ADDED
@@ -0,0 +1,339 @@
1
+ export * from './types';
2
+ const DEFAULT_LOCAL_URL = 'http://localhost:3000';
3
+ const DEFAULT_WORKSPACE_MOUNT = '/home/daytona/agent-workspace';
4
+ export class CastariClient {
5
+ ws;
6
+ options;
7
+ messageHandlers = [];
8
+ sandbox;
9
+ daytona;
10
+ constructor(options = {}) {
11
+ this.options = {
12
+ ...options,
13
+ };
14
+ }
15
+ async start() {
16
+ const anthropicApiKey = this.options.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
17
+ if (!anthropicApiKey) {
18
+ throw new Error('ANTHROPIC_API_KEY is required');
19
+ }
20
+ const connection = this.options.connectionUrl
21
+ ? await this.setupLocalConnection()
22
+ : await this.setupDaytonaConnection();
23
+ if (this.options.debug) {
24
+ console.log(`📡 Configuring server at ${connection.configUrl}...`);
25
+ }
26
+ const configPayload = {
27
+ anthropicApiKey,
28
+ agents: this.options.agents,
29
+ allowedTools: this.options.allowedTools,
30
+ systemPrompt: this.options.systemPrompt,
31
+ model: this.options.model,
32
+ };
33
+ const configHeaders = {
34
+ 'Content-Type': 'application/json',
35
+ };
36
+ if (connection.previewToken) {
37
+ configHeaders['x-daytona-preview-token'] = connection.previewToken;
38
+ }
39
+ let configResponse = null;
40
+ const maxConfigAttempts = 10;
41
+ for (let attempt = 1; attempt <= maxConfigAttempts; attempt++) {
42
+ configResponse = await fetch(connection.configUrl, {
43
+ method: 'POST',
44
+ headers: configHeaders,
45
+ body: JSON.stringify(configPayload),
46
+ }).catch(err => {
47
+ if (this.options.debug) {
48
+ console.warn(`⚠️ Config request failed on attempt ${attempt}:`, err);
49
+ }
50
+ return null;
51
+ });
52
+ if (configResponse && configResponse.ok)
53
+ break;
54
+ if (this.options.debug) {
55
+ console.warn(`⚠️ Config attempt ${attempt} failed (status ${configResponse?.status ?? 'n/a'}).`);
56
+ }
57
+ if (attempt < maxConfigAttempts) {
58
+ await new Promise(resolve => setTimeout(resolve, 3000));
59
+ }
60
+ }
61
+ if (!configResponse || !configResponse.ok) {
62
+ const errorText = configResponse ? await configResponse.text() : 'no response';
63
+ if (connection.cleanup)
64
+ await connection.cleanup();
65
+ throw new Error(`Failed to configure server (status ${configResponse?.status ?? 'n/a'}): ${errorText}`);
66
+ }
67
+ const { connectionToken } = (await configResponse.json());
68
+ if (!connectionToken) {
69
+ if (connection.cleanup)
70
+ await connection.cleanup();
71
+ throw new Error('Server did not return a connectionToken');
72
+ }
73
+ const wsUrlParams = new URLSearchParams();
74
+ wsUrlParams.set('token', connectionToken);
75
+ if (connection.previewToken) {
76
+ wsUrlParams.set('preview_token', connection.previewToken);
77
+ }
78
+ const wsUrlJoiner = connection.wsUrl.includes('?') ? '&' : '?';
79
+ const wsUrl = `${connection.wsUrl}${wsUrlJoiner}${wsUrlParams.toString()}`;
80
+ if (this.options.debug) {
81
+ console.log(`🔌 Connecting to WebSocket at ${wsUrl}...`);
82
+ }
83
+ return new Promise((resolve, reject) => {
84
+ this.ws = new WebSocket(wsUrl);
85
+ this.ws.onopen = () => {
86
+ if (this.options.debug)
87
+ console.log('✅ Connected to Castari Server');
88
+ resolve();
89
+ };
90
+ this.ws.onmessage = event => {
91
+ try {
92
+ const message = JSON.parse(event.data.toString());
93
+ this.handleMessage(message);
94
+ }
95
+ catch (error) {
96
+ console.error('Failed to parse message:', error);
97
+ }
98
+ };
99
+ this.ws.onerror = error => {
100
+ console.error('WebSocket error:', error);
101
+ reject(error);
102
+ };
103
+ this.ws.onclose = () => {
104
+ if (this.options.debug)
105
+ console.log('👋 Disconnected');
106
+ };
107
+ });
108
+ }
109
+ async setupLocalConnection() {
110
+ const baseUrl = (this.options.connectionUrl || DEFAULT_LOCAL_URL).replace(/\/$/, '');
111
+ return {
112
+ configUrl: `${baseUrl.replace('ws://', 'http://').replace('wss://', 'https://')}/config`,
113
+ wsUrl: `${baseUrl.replace('http://', 'ws://').replace('https://', 'wss://')}/ws`,
114
+ };
115
+ }
116
+ async setupDaytonaConnection() {
117
+ const { Daytona } = await import('@daytonaio/sdk');
118
+ const apiKey = this.options.daytonaApiKey || process.env.DAYTONA_API_KEY;
119
+ if (!apiKey) {
120
+ throw new Error('DAYTONA_API_KEY is required for Daytona mode');
121
+ }
122
+ this.daytona = new Daytona({
123
+ apiKey,
124
+ apiUrl: this.options.daytonaApiUrl || process.env.DAYTONA_API_URL,
125
+ target: this.options.daytonaTarget || process.env.DAYTONA_TARGET,
126
+ });
127
+ const volumeName = this.options.volume || this.options.workspaceVolumeName;
128
+ let volumeMounts = [];
129
+ if (volumeName) {
130
+ const volumeService = this.daytona.volume;
131
+ if (volumeService && typeof volumeService.get === 'function') {
132
+ // Ensure volume exists
133
+ let volume;
134
+ try {
135
+ volume = await volumeService.get(volumeName, true); // true = create if missing
136
+ }
137
+ catch (err) {
138
+ // If get fails, try create explicitly if the SDK requires it, but usually get(name, true) handles it.
139
+ // Assuming get(name, true) works as per previous implementation.
140
+ console.error('Failed to get/create volume:', err);
141
+ throw err;
142
+ }
143
+ volumeMounts = [
144
+ {
145
+ volumeId: volume.id,
146
+ mountPath: DEFAULT_WORKSPACE_MOUNT,
147
+ },
148
+ ];
149
+ if (this.options.debug) {
150
+ console.log(`🗂️ Using Daytona volume ${volume.id} (${volumeName}) at ${DEFAULT_WORKSPACE_MOUNT}`);
151
+ }
152
+ }
153
+ else {
154
+ console.warn('Daytona SDK does not expose volume.get; skipping volume mount. Update @daytonaio/sdk or disable volume.');
155
+ }
156
+ }
157
+ if (this.options.debug) {
158
+ console.log('🚀 Creating Daytona sandbox...');
159
+ if (volumeName) {
160
+ console.log(`📦 Mounting volume ${volumeName} at ${DEFAULT_WORKSPACE_MOUNT}`);
161
+ }
162
+ else {
163
+ console.log('📦 No volume configured; using ephemeral container filesystem.');
164
+ }
165
+ }
166
+ const createParams = {
167
+ snapshot: this.options.snapshot,
168
+ image: this.options.image,
169
+ resources: this.options.resources,
170
+ autoStopInterval: this.options.autoStopInterval,
171
+ autoArchiveInterval: this.options.autoArchiveInterval,
172
+ autoDeleteInterval: this.options.autoDeleteInterval,
173
+ ephemeral: this.options.ephemeral,
174
+ public: true,
175
+ volumes: volumeMounts,
176
+ };
177
+ this.sandbox = await this.daytona.create(createParams);
178
+ if (this.options.debug) {
179
+ console.log(`✅ Sandbox created: ${this.sandbox.id}`);
180
+ }
181
+ // Wait for sandbox to reach STARTED state before interacting with it
182
+ const sandboxAny = this.sandbox;
183
+ if (typeof sandboxAny.waitUntilStarted === 'function') {
184
+ if (this.options.debug) {
185
+ console.log('⏳ Waiting for sandbox to reach STARTED state...');
186
+ }
187
+ await sandboxAny.waitUntilStarted(60);
188
+ }
189
+ // Start the Castari server process inside the sandbox.
190
+ // We keep the container's default ENTRYPOINT as a no-op ('sleep infinity') for snapshot validation,
191
+ // and explicitly start the server here via Daytona's process API, invoking the server entrypoint directly.
192
+ const serverCommand = `sh -lc "cd /home/daytona/app && CASTARI_WORKSPACE=${DEFAULT_WORKSPACE_MOUNT} bun start"`;
193
+ try {
194
+ const sessionId = `castari-server-${this.sandbox.id}`;
195
+ const processApi = this.sandbox.process;
196
+ if (processApi &&
197
+ typeof processApi.createSession === 'function' &&
198
+ typeof processApi.executeSessionCommand === 'function') {
199
+ if (this.options.debug) {
200
+ console.log(`▶️ Creating process session ${sessionId} and starting server...`);
201
+ }
202
+ await processApi.createSession(sessionId);
203
+ await processApi.executeSessionCommand(sessionId, {
204
+ command: serverCommand,
205
+ async: true,
206
+ });
207
+ }
208
+ else if (processApi && typeof processApi.executeCommand === 'function') {
209
+ if (this.options.debug) {
210
+ console.log('▶️ Starting server via executeCommand...');
211
+ }
212
+ await processApi.executeCommand({
213
+ command: serverCommand,
214
+ async: true,
215
+ });
216
+ }
217
+ else {
218
+ console.warn('Daytona SDK process API is not available; unable to start server automatically inside sandbox.');
219
+ }
220
+ // Give the server a brief moment to start listening on port 3000 before configuring it.
221
+ if (this.options.debug) {
222
+ console.log('⏳ Waiting briefly for Castari server to start inside sandbox...');
223
+ }
224
+ await new Promise(resolve => setTimeout(resolve, 3000));
225
+ }
226
+ catch (err) {
227
+ console.error('Failed to start Castari server inside sandbox:', err);
228
+ }
229
+ // If user supplies a direct connection URL, use it to avoid preview discovery
230
+ let baseUrl = this.options.daytonaConnectionUrl || process.env.DAYTONA_CONNECTION_URL || null;
231
+ let previewToken;
232
+ if (!baseUrl) {
233
+ // Try getPreviewUrl (newer SDKs), then getPreviewLink (older SDKs)
234
+ let preview = null;
235
+ if (typeof this.sandbox.getPreviewUrl === 'function') {
236
+ try {
237
+ preview = await this.sandbox.getPreviewUrl(3000);
238
+ }
239
+ catch (err) {
240
+ if (this.options.debug) {
241
+ console.warn('⚠️ getPreviewUrl failed; will fall back to getPreviewLink or manual domain.', err);
242
+ }
243
+ }
244
+ }
245
+ if (!preview?.url && typeof this.sandbox.getPreviewLink === 'function') {
246
+ try {
247
+ preview = await this.sandbox.getPreviewLink(3000);
248
+ }
249
+ catch (err) {
250
+ if (this.options.debug) {
251
+ console.warn('⚠️ getPreviewLink failed; will attempt fallback domain if provided.', err);
252
+ }
253
+ }
254
+ }
255
+ baseUrl = preview?.url || null;
256
+ previewToken = preview?.token;
257
+ if (!baseUrl) {
258
+ // Fallback: construct from provided preview domain if available
259
+ const previewDomain = this.options.daytonaPreviewDomain || process.env.DAYTONA_PREVIEW_DOMAIN;
260
+ if (previewDomain) {
261
+ baseUrl = `https://${3000}-${this.sandbox.id}.${previewDomain}`;
262
+ if (this.options.debug) {
263
+ console.warn(`⚠️ Preview URL not provided by SDK; using fallback domain ${previewDomain}`);
264
+ }
265
+ }
266
+ else {
267
+ throw new Error('Failed to get preview URL from Daytona sandbox. Provide daytonaConnectionUrl or DAYTONA_PREVIEW_DOMAIN.');
268
+ }
269
+ }
270
+ }
271
+ baseUrl = baseUrl.replace(/\/$/, '');
272
+ const configUrl = `${baseUrl.replace('ws://', 'http://').replace('wss://', 'https://')}/config`;
273
+ const wsUrlBase = `${baseUrl.replace('https://', 'wss://').replace('http://', 'ws://')}/ws`;
274
+ return {
275
+ configUrl,
276
+ wsUrl: wsUrlBase,
277
+ previewToken,
278
+ cleanup: async () => {
279
+ try {
280
+ await this.sandbox?.delete();
281
+ if (this.options.debug)
282
+ console.log('🧹 Sandbox deleted');
283
+ }
284
+ catch (err) {
285
+ const msg = err?.response?.data?.message || err?.message || String(err);
286
+ if (msg.includes('state change in progress')) {
287
+ if (this.options.debug) {
288
+ console.warn('⚠️ Sandbox deletion skipped (state change in progress)');
289
+ }
290
+ }
291
+ else {
292
+ console.error('Failed to delete sandbox:', err);
293
+ }
294
+ }
295
+ },
296
+ };
297
+ }
298
+ handleMessage(message) {
299
+ if (this.options.debug) {
300
+ console.log('📨 Received message:', JSON.stringify(message, null, 2));
301
+ }
302
+ this.messageHandlers.forEach(handler => handler(message));
303
+ }
304
+ onMessage(handler) {
305
+ this.messageHandlers.push(handler);
306
+ return () => {
307
+ this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
308
+ };
309
+ }
310
+ send(message) {
311
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
312
+ throw new Error('WebSocket is not connected');
313
+ }
314
+ this.ws.send(JSON.stringify(message));
315
+ }
316
+ async stop() {
317
+ if (this.ws) {
318
+ this.ws.close();
319
+ }
320
+ if (this.sandbox) {
321
+ try {
322
+ await this.sandbox.delete();
323
+ if (this.options.debug)
324
+ console.log('🧹 Sandbox deleted');
325
+ }
326
+ catch (err) {
327
+ const msg = err?.response?.data?.message || err?.message || String(err);
328
+ if (msg.includes('state change in progress')) {
329
+ if (this.options.debug) {
330
+ console.warn('⚠️ Sandbox deletion skipped (state change in progress)');
331
+ }
332
+ }
333
+ else {
334
+ console.error('Failed to delete sandbox:', err);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Application constants
3
+ */
4
+ export declare const SERVER_PORT = 3000;
5
+ export declare const WORKSPACE_DIR_NAME = "agent-workspace";
6
+ export declare const CONNECTION_TOKEN_TTL_MS: number;
package/dist/const.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Application constants
3
+ */
4
+ // Server configuration
5
+ export const SERVER_PORT = 3000;
6
+ // Workspace configuration
7
+ export const WORKSPACE_DIR_NAME = 'agent-workspace';
8
+ // Connection token (one-time) configuration
9
+ export const CONNECTION_TOKEN_TTL_MS = 5 * 60 * 10000; // 5 minutes
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './server';
3
+ export * from './client';
4
+ export { tool } from '@anthropic-ai/claude-agent-sdk';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './server';
3
+ export * from './client';
4
+ export { tool } from '@anthropic-ai/claude-agent-sdk';
@@ -0,0 +1,8 @@
1
+ import { query, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ import { type ServerWebSocket } from 'bun';
3
+ export type MessageHandlerContext = {
4
+ messageQueue: SDKUserMessage[];
5
+ getActiveStream: () => ReturnType<typeof query> | null;
6
+ workspaceDirectory: string;
7
+ };
8
+ export declare function handleMessage(ws: ServerWebSocket, message: string | Buffer, context: MessageHandlerContext): Promise<void>;
@@ -0,0 +1,96 @@
1
+ import { readdir, readFile, unlink, writeFile } from 'fs/promises';
2
+ import { join } from 'path';
3
+ export async function handleMessage(ws, message, context) {
4
+ try {
5
+ const input = JSON.parse(message.toString());
6
+ const { messageQueue, getActiveStream, workspaceDirectory } = context;
7
+ if (input.type === 'user_message') {
8
+ messageQueue.push(input.data);
9
+ }
10
+ else if (input.type === 'interrupt') {
11
+ getActiveStream()?.interrupt();
12
+ }
13
+ else if (input.type === 'create_file') {
14
+ const targetPath = join(workspaceDirectory, input.path);
15
+ const encoding = input.encoding || 'utf-8';
16
+ const content = encoding === 'base64'
17
+ ? Buffer.from(input.content, 'base64')
18
+ : input.content;
19
+ try {
20
+ await writeFile(targetPath, content);
21
+ ws.send(JSON.stringify({
22
+ type: 'file_result',
23
+ operation: 'create_file',
24
+ result: 'success',
25
+ }));
26
+ }
27
+ catch (err) {
28
+ ws.send(JSON.stringify({
29
+ type: 'error',
30
+ error: `Failed to create file: ${err instanceof Error ? err.message : String(err)}`,
31
+ }));
32
+ }
33
+ }
34
+ else if (input.type === 'read_file') {
35
+ const targetPath = join(workspaceDirectory, input.path);
36
+ const encoding = input.encoding || 'utf-8';
37
+ try {
38
+ const content = await readFile(targetPath, encoding === 'base64' ? 'base64' : 'utf-8');
39
+ ws.send(JSON.stringify({
40
+ type: 'file_result',
41
+ operation: 'read_file',
42
+ result: content,
43
+ encoding,
44
+ }));
45
+ }
46
+ catch (err) {
47
+ ws.send(JSON.stringify({
48
+ type: 'error',
49
+ error: `Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
50
+ }));
51
+ }
52
+ }
53
+ else if (input.type === 'delete_file') {
54
+ const targetPath = join(workspaceDirectory, input.path);
55
+ try {
56
+ await unlink(targetPath);
57
+ ws.send(JSON.stringify({
58
+ type: 'file_result',
59
+ operation: 'delete_file',
60
+ result: 'success',
61
+ }));
62
+ }
63
+ catch (err) {
64
+ ws.send(JSON.stringify({
65
+ type: 'error',
66
+ error: `Failed to delete file: ${err instanceof Error ? err.message : String(err)}`,
67
+ }));
68
+ }
69
+ }
70
+ else if (input.type === 'list_files') {
71
+ const targetPath = input.path
72
+ ? join(workspaceDirectory, input.path)
73
+ : workspaceDirectory;
74
+ try {
75
+ const files = await readdir(targetPath);
76
+ ws.send(JSON.stringify({
77
+ type: 'file_result',
78
+ operation: 'list_files',
79
+ result: files,
80
+ }));
81
+ }
82
+ catch (err) {
83
+ ws.send(JSON.stringify({
84
+ type: 'error',
85
+ error: `Failed to list files: ${err instanceof Error ? err.message : String(err)}`,
86
+ }));
87
+ }
88
+ }
89
+ }
90
+ catch (error) {
91
+ ws.send(JSON.stringify({
92
+ type: 'error',
93
+ error: `Invalid message format: ${error instanceof Error ? error.message : String(error)}`,
94
+ }));
95
+ }
96
+ }
@@ -0,0 +1,5 @@
1
+ import { type Options } from '@anthropic-ai/claude-agent-sdk';
2
+ export type CastariServerOptions = Partial<Options> & {
3
+ port?: number;
4
+ };
5
+ export declare function serve(options?: CastariServerOptions): Promise<void>;
package/dist/server.js ADDED
@@ -0,0 +1,199 @@
1
+ import { randomBytes } from 'crypto';
2
+ import { mkdir } from 'fs/promises';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ import { query, } from '@anthropic-ai/claude-agent-sdk';
6
+ import { CONNECTION_TOKEN_TTL_MS, SERVER_PORT, WORKSPACE_DIR_NAME, } from './const';
7
+ import { handleMessage } from './message-handler';
8
+ const workspaceDirectory = process.env.CASTARI_WORKSPACE || join(homedir(), WORKSPACE_DIR_NAME);
9
+ // Single WebSocket connection (only one allowed)
10
+ let activeConnection = null;
11
+ // Message queue
12
+ const messageQueue = [];
13
+ // Stream reference for interrupts
14
+ let activeStream = null;
15
+ // Stored query configuration
16
+ let queryConfig = {};
17
+ // Connection tokens
18
+ const connectionTokens = new Map();
19
+ async function ensureWorkspace() {
20
+ await mkdir(workspaceDirectory, { recursive: true });
21
+ }
22
+ function generateConnectionToken() {
23
+ const value = randomBytes(24).toString('hex');
24
+ const token = {
25
+ value,
26
+ createdAt: Date.now(),
27
+ used: false,
28
+ };
29
+ connectionTokens.set(value, token);
30
+ return value;
31
+ }
32
+ function cleanupTokens() {
33
+ const now = Date.now();
34
+ for (const [value, token] of connectionTokens.entries()) {
35
+ if (token.used || now - token.createdAt > CONNECTION_TOKEN_TTL_MS) {
36
+ connectionTokens.delete(value);
37
+ }
38
+ }
39
+ }
40
+ function validateAndUseToken(value) {
41
+ cleanupTokens();
42
+ if (!value)
43
+ return false;
44
+ const token = connectionTokens.get(value);
45
+ if (!token)
46
+ return false;
47
+ const isExpired = Date.now() - token.createdAt > CONNECTION_TOKEN_TTL_MS;
48
+ if (token.used || isExpired) {
49
+ connectionTokens.delete(value);
50
+ return false;
51
+ }
52
+ token.used = true;
53
+ connectionTokens.set(value, token);
54
+ return true;
55
+ }
56
+ // Create an async generator that yields messages from the queue
57
+ async function* generateMessages() {
58
+ while (true) {
59
+ while (messageQueue.length > 0) {
60
+ const message = messageQueue.shift();
61
+ if (message) {
62
+ yield message;
63
+ }
64
+ }
65
+ await new Promise(resolve => setTimeout(resolve, 10));
66
+ }
67
+ }
68
+ // Process messages from the SDK and send to WebSocket client
69
+ async function processMessages(initialOptions) {
70
+ try {
71
+ const options = {
72
+ settingSources: ['local'],
73
+ cwd: workspaceDirectory,
74
+ // Auto-approve tool usage (including file writes) inside the sandbox.
75
+ // Daytona sandboxes are already isolated, so this keeps DX smooth without interactive prompts.
76
+ canUseTool: async (_toolName, input) => ({
77
+ behavior: 'allow',
78
+ updatedInput: input,
79
+ }),
80
+ stderr: data => {
81
+ if (activeConnection) {
82
+ const output = {
83
+ type: 'info',
84
+ data,
85
+ };
86
+ activeConnection.send(JSON.stringify(output));
87
+ }
88
+ },
89
+ ...initialOptions, // Merge initial options (tools, systemPrompt, etc.)
90
+ ...queryConfig, // Merge dynamic config from /config endpoint (overrides initial)
91
+ ...(queryConfig.anthropicApiKey || process.env.ANTHROPIC_API_KEY
92
+ ? {
93
+ env: {
94
+ PATH: process.env.PATH,
95
+ ANTHROPIC_API_KEY: queryConfig.anthropicApiKey || process.env.ANTHROPIC_API_KEY,
96
+ },
97
+ }
98
+ : {}),
99
+ };
100
+ console.info('Starting query with options', {
101
+ ...options,
102
+ prompt: '[generator]', // avoid logging generator internals
103
+ });
104
+ activeStream = query({
105
+ prompt: generateMessages(),
106
+ options,
107
+ });
108
+ for await (const message of activeStream) {
109
+ if (activeConnection) {
110
+ const output = {
111
+ type: 'sdk_message',
112
+ data: message,
113
+ };
114
+ activeConnection.send(JSON.stringify(output));
115
+ }
116
+ }
117
+ }
118
+ catch (error) {
119
+ console.error('Error processing messages:', error);
120
+ if (activeConnection) {
121
+ const output = {
122
+ type: 'error',
123
+ error: error instanceof Error ? error.message : 'Unknown error',
124
+ };
125
+ activeConnection.send(JSON.stringify(output));
126
+ }
127
+ }
128
+ }
129
+ export async function serve(options = {}) {
130
+ await ensureWorkspace();
131
+ // Create WebSocket server
132
+ const server = Bun.serve({
133
+ port: options.port || SERVER_PORT,
134
+ async fetch(req, server) {
135
+ const url = new URL(req.url);
136
+ // Configuration endpoint
137
+ if (url.pathname === '/config' && req.method === 'POST') {
138
+ try {
139
+ const config = (await req.json());
140
+ queryConfig = config;
141
+ const connectionToken = generateConnectionToken();
142
+ return Response.json({
143
+ success: true,
144
+ config: queryConfig,
145
+ connectionToken,
146
+ });
147
+ }
148
+ catch {
149
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 });
150
+ }
151
+ }
152
+ // Get current configuration
153
+ if (url.pathname === '/config' && req.method === 'GET') {
154
+ return Response.json({ config: queryConfig });
155
+ }
156
+ // WebSocket endpoint
157
+ if (url.pathname === '/ws') {
158
+ const token = url.searchParams.get('token');
159
+ if (!validateAndUseToken(token)) {
160
+ return new Response('Unauthorized', { status: 401 });
161
+ }
162
+ if (activeConnection) {
163
+ return new Response('Server already has an active connection', {
164
+ status: 409,
165
+ });
166
+ }
167
+ if (server.upgrade(req))
168
+ return;
169
+ }
170
+ return new Response('Not Found', { status: 404 });
171
+ },
172
+ websocket: {
173
+ open(ws) {
174
+ activeConnection = ws;
175
+ // Start processing messages when first connection is made
176
+ if (!activeStream) {
177
+ processMessages(options);
178
+ }
179
+ const output = { type: 'connected' };
180
+ ws.send(JSON.stringify(output));
181
+ },
182
+ async message(ws, message) {
183
+ await handleMessage(ws, message, {
184
+ messageQueue,
185
+ getActiveStream: () => activeStream,
186
+ workspaceDirectory,
187
+ });
188
+ },
189
+ close(ws) {
190
+ if (activeConnection === ws) {
191
+ activeConnection = null;
192
+ }
193
+ },
194
+ },
195
+ });
196
+ console.log(`🚀 Castari Server running on http://localhost:${server.port}`);
197
+ console.log(` Config endpoint: http://localhost:${server.port}/config`);
198
+ console.log(` WebSocket endpoint: ws://localhost:${server.port}/ws?token=<token>`);
199
+ }
@@ -0,0 +1,58 @@
1
+ import { type AgentDefinition, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ export type WSInputMessage = {
3
+ type: 'user_message';
4
+ data: SDKUserMessage;
5
+ } | {
6
+ type: 'interrupt';
7
+ } | {
8
+ type: 'create_file';
9
+ path: string;
10
+ content: string;
11
+ encoding?: 'utf-8' | 'base64';
12
+ } | {
13
+ type: 'read_file';
14
+ path: string;
15
+ encoding?: 'utf-8' | 'base64';
16
+ } | {
17
+ type: 'delete_file';
18
+ path: string;
19
+ } | {
20
+ type: 'list_files';
21
+ path?: string;
22
+ };
23
+ export type WSOutputMessage = {
24
+ type: 'connected';
25
+ } | {
26
+ type: 'sdk_message';
27
+ data: SDKMessage;
28
+ } | {
29
+ type: 'error';
30
+ error: string;
31
+ } | {
32
+ type: 'info';
33
+ data: string;
34
+ } | {
35
+ type: 'file_result';
36
+ operation: 'create_file' | 'delete_file';
37
+ result: 'success';
38
+ } | {
39
+ type: 'file_result';
40
+ operation: 'read_file';
41
+ result: string;
42
+ encoding: 'utf-8' | 'base64';
43
+ } | {
44
+ type: 'file_result';
45
+ operation: 'list_files';
46
+ result: string[];
47
+ };
48
+ export type QueryConfig = {
49
+ agents?: Record<string, AgentDefinition>;
50
+ allowedTools?: string[];
51
+ systemPrompt?: string | {
52
+ type: 'preset';
53
+ preset: 'claude_code';
54
+ append?: string;
55
+ };
56
+ model?: string;
57
+ anthropicApiKey?: string;
58
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@castari/sdk",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "license": "UNLICENSED",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "import": "./dist/index.js",
20
+ "types": "./dist/index.d.ts"
21
+ },
22
+ "./client": {
23
+ "import": "./dist/client.js",
24
+ "types": "./dist/client.d.ts"
25
+ },
26
+ "./server": {
27
+ "import": "./dist/server.js",
28
+ "types": "./dist/server.d.ts"
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@anthropic-ai/claude-agent-sdk": "^0.1.44",
33
+ "@daytonaio/sdk": "^0.115.2"
34
+ },
35
+ "devDependencies": {
36
+ "@types/bun": "latest"
37
+ }
38
+ }