@banata-boxes/sdk 0.1.0 → 0.1.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.
package/src/index.ts CHANGED
@@ -84,6 +84,18 @@ export interface BillingInfo {
84
84
  };
85
85
  }
86
86
 
87
+ export interface LaunchedBrowserSession {
88
+ cdpUrl: string;
89
+ sessionId: string;
90
+ previewViewerUrl: string | null;
91
+ close: () => Promise<void>;
92
+ getPreviewConnection: () => Promise<PreviewConnectionInfo | null>;
93
+ getPreviewViewerUrl: () => Promise<string | null>;
94
+ startPreview: () => Promise<PreviewCommandResponse>;
95
+ navigatePreview: (url: string) => Promise<PreviewCommandResponse>;
96
+ resizePreview: (size: { width?: number; height?: number }) => Promise<PreviewCommandResponse>;
97
+ }
98
+
87
99
  export type WebhookEventType =
88
100
  | "session.created"
89
101
  | "session.assigned"
@@ -152,6 +164,76 @@ export interface RetryConfig {
152
164
  maxDelayMs?: number;
153
165
  }
154
166
 
167
+ export type JsonValue =
168
+ | string
169
+ | number
170
+ | boolean
171
+ | null
172
+ | JsonValue[]
173
+ | { [key: string]: JsonValue };
174
+
175
+ export interface PreviewConnectionInfo {
176
+ rawUrl: string;
177
+ token: string;
178
+ sessionId: string;
179
+ wsUrl: string;
180
+ startUrl: string;
181
+ navigateUrl: string;
182
+ resizeUrl: string;
183
+ }
184
+
185
+ export function buildPreviewViewerUrl(
186
+ connectionUrl: string | null | undefined,
187
+ appUrl: string = "https://boxes.banata.dev",
188
+ ): string | null {
189
+ if (!connectionUrl) {
190
+ return null;
191
+ }
192
+
193
+ try {
194
+ const parsed = new URL(connectionUrl);
195
+ const token = parsed.searchParams.get("token");
196
+ const session = parsed.searchParams.get("session");
197
+ const machine = parsed.searchParams.get("machine");
198
+ if (!token || !session) {
199
+ return null;
200
+ }
201
+
202
+ const backendProtocol =
203
+ parsed.protocol === "wss:"
204
+ ? "https:"
205
+ : parsed.protocol === "ws:"
206
+ ? "http:"
207
+ : parsed.protocol;
208
+ const viewerUrl = new URL("/preview", appUrl);
209
+ viewerUrl.searchParams.set("backend", `${backendProtocol}//${parsed.host}`);
210
+ viewerUrl.searchParams.set("token", token);
211
+ viewerUrl.searchParams.set("session", session);
212
+ if (machine) {
213
+ viewerUrl.searchParams.set("machine", machine);
214
+ }
215
+ return viewerUrl.toString();
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ export interface PreviewCommandResponse<TPreview = unknown> {
222
+ ok: boolean;
223
+ preview?: TPreview;
224
+ [key: string]: unknown;
225
+ }
226
+
227
+ export interface OpenCodeConnectionInfo {
228
+ rawUrl: string;
229
+ token: string;
230
+ sessionId: string;
231
+ stateUrl: string;
232
+ messagesUrl: string;
233
+ promptUrl: string;
234
+ eventsUrl: string;
235
+ }
236
+
155
237
  export class BanataError extends Error {
156
238
  /** HTTP status code (0 for network errors) */
157
239
  status: number;
@@ -189,15 +271,239 @@ function sleep(ms: number): Promise<void> {
189
271
  return new Promise((r) => setTimeout(r, ms));
190
272
  }
191
273
 
274
+ function derivePreviewConnection(
275
+ rawUrl: string | null | undefined,
276
+ ): PreviewConnectionInfo | null {
277
+ if (!rawUrl) {
278
+ return null;
279
+ }
280
+
281
+ try {
282
+ const parsed = new URL(rawUrl);
283
+ const token = parsed.searchParams.get("token");
284
+ const sessionId = parsed.searchParams.get("session");
285
+ if (!token || !sessionId) {
286
+ return null;
287
+ }
288
+
289
+ const httpProtocol =
290
+ parsed.protocol === "https:" || parsed.protocol === "wss:"
291
+ ? "https:"
292
+ : "http:";
293
+ const wsProtocol =
294
+ parsed.protocol === "https:"
295
+ ? "wss:"
296
+ : parsed.protocol === "http:"
297
+ ? "ws:"
298
+ : parsed.protocol;
299
+ const search = `token=${encodeURIComponent(token)}&session=${encodeURIComponent(sessionId)}`;
300
+ const isDirectPreviewUrl =
301
+ parsed.pathname.endsWith("/ws") && parsed.pathname.includes("/preview/");
302
+
303
+ if (isDirectPreviewUrl) {
304
+ const basePath = parsed.pathname.slice(0, -3);
305
+ return {
306
+ rawUrl,
307
+ token,
308
+ sessionId,
309
+ startUrl: `${httpProtocol}//${parsed.host}${basePath}/start?${search}`,
310
+ navigateUrl: `${httpProtocol}//${parsed.host}${basePath}/navigate?${search}`,
311
+ resizeUrl: `${httpProtocol}//${parsed.host}${basePath}/resize?${search}`,
312
+ wsUrl: `${wsProtocol}//${parsed.host}${parsed.pathname}?${search}`,
313
+ };
314
+ }
315
+
316
+ return {
317
+ rawUrl,
318
+ token,
319
+ sessionId,
320
+ startUrl: `${httpProtocol}//${parsed.host}/preview/start?${search}`,
321
+ navigateUrl: `${httpProtocol}//${parsed.host}/preview/navigate?${search}`,
322
+ resizeUrl: `${httpProtocol}//${parsed.host}/preview/resize?${search}`,
323
+ wsUrl: `${wsProtocol}//${parsed.host}/preview/ws?${search}`,
324
+ };
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ function deriveOpenCodeConnection(
331
+ rawUrl: string | null | undefined,
332
+ ): OpenCodeConnectionInfo | null {
333
+ if (!rawUrl) {
334
+ return null;
335
+ }
336
+
337
+ try {
338
+ const parsed = new URL(rawUrl);
339
+ const token = parsed.searchParams.get("token");
340
+ const sessionId = parsed.searchParams.get("session");
341
+ if (!token || !sessionId) {
342
+ return null;
343
+ }
344
+
345
+ const httpProtocol =
346
+ parsed.protocol === "https:" || parsed.protocol === "wss:"
347
+ ? "https:"
348
+ : "http:";
349
+ const search = `token=${encodeURIComponent(token)}&session=${encodeURIComponent(sessionId)}`;
350
+
351
+ return {
352
+ rawUrl,
353
+ token,
354
+ sessionId,
355
+ stateUrl: `${httpProtocol}//${parsed.host}/opencode/state?${search}`,
356
+ messagesUrl: `${httpProtocol}//${parsed.host}/opencode/messages?${search}`,
357
+ promptUrl: `${httpProtocol}//${parsed.host}/opencode/prompt-async?${search}`,
358
+ eventsUrl: `${httpProtocol}//${parsed.host}/opencode/events?${search}`,
359
+ };
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ async function parseJsonResponse(response: Response): Promise<unknown> {
366
+ const text = await response.text();
367
+ if (!text) {
368
+ return null;
369
+ }
370
+
371
+ try {
372
+ return JSON.parse(text);
373
+ } catch {
374
+ return text;
375
+ }
376
+ }
377
+
378
+ async function requestPreviewEndpoint<T>(
379
+ url: string,
380
+ init: RequestInit,
381
+ ): Promise<T> {
382
+ const response = await fetch(url, init);
383
+ const payload = await parseJsonResponse(response);
384
+
385
+ if (!response.ok) {
386
+ const message =
387
+ payload && typeof payload === "object" && !Array.isArray(payload) && typeof (payload as Record<string, unknown>).error === "string"
388
+ ? String((payload as Record<string, unknown>).error)
389
+ : typeof payload === "string"
390
+ ? payload
391
+ : `Preview request failed (${response.status})`;
392
+ throw new BanataError(message, response.status);
393
+ }
394
+
395
+ return payload as T;
396
+ }
397
+
398
+ function shouldFallbackFromDirectConnection(error: unknown): boolean {
399
+ if (error instanceof BanataError) {
400
+ return error.status === 0 || error.status >= 500;
401
+ }
402
+
403
+ if (error instanceof Error) {
404
+ const maybeCause = error.cause as
405
+ | { code?: string; name?: string }
406
+ | undefined;
407
+ const code = maybeCause?.code;
408
+ return (
409
+ code === "ECONNRESET" ||
410
+ code === "ECONNREFUSED" ||
411
+ code === "ENOTFOUND" ||
412
+ code === "ETIMEDOUT" ||
413
+ code === "UND_ERR_CONNECT_TIMEOUT" ||
414
+ error.name === "TypeError"
415
+ );
416
+ }
417
+
418
+ return false;
419
+ }
420
+
421
+ function parseSseBlock(block: string): { type: string; data: JsonValue | string | null; raw: string } | null {
422
+ const trimmed = block.trim();
423
+ if (!trimmed) {
424
+ return null;
425
+ }
426
+
427
+ let eventType = "message";
428
+ const dataLines: string[] = [];
429
+
430
+ for (const line of trimmed.split(/\r?\n/)) {
431
+ if (line.startsWith("event:")) {
432
+ eventType = line.slice("event:".length).trim() || "message";
433
+ continue;
434
+ }
435
+ if (line.startsWith("data:")) {
436
+ dataLines.push(line.slice("data:".length).trimStart());
437
+ }
438
+ }
439
+
440
+ const rawData = dataLines.join("\n");
441
+ if (!rawData) {
442
+ return { type: eventType, data: null, raw: trimmed };
443
+ }
444
+
445
+ try {
446
+ return {
447
+ type: eventType,
448
+ data: JSON.parse(rawData) as JsonValue,
449
+ raw: trimmed,
450
+ };
451
+ } catch {
452
+ return {
453
+ type: eventType,
454
+ data: rawData,
455
+ raw: trimmed,
456
+ };
457
+ }
458
+ }
459
+
460
+ function isBrowserSessionOperationallyReady(
461
+ session: BrowserSession,
462
+ ): session is BrowserSession & { cdpUrl: string } {
463
+ return (
464
+ (session.status === "ready" || session.status === "active") &&
465
+ Boolean(session.cdpUrl)
466
+ );
467
+ }
468
+
469
+ function isSandboxSessionOperationallyReady(
470
+ session: SandboxSession,
471
+ ): boolean {
472
+ if (session.status !== "ready" && session.status !== "active") {
473
+ return false;
474
+ }
475
+
476
+ const browserMode = session.capabilities?.browser?.mode ?? "none";
477
+ if (browserMode !== "none") {
478
+ if (!session.pairedBrowser?.cdpUrl) {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ if (session.capabilities?.opencode?.enabled) {
484
+ if (
485
+ !session.opencode ||
486
+ session.opencode.status === "failed" ||
487
+ session.opencode.status === "disabled"
488
+ ) {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ return true;
494
+ }
495
+
192
496
  export class BrowserCloud {
193
497
  private apiKey: string;
194
498
  private baseUrl: string;
499
+ private appUrl: string;
195
500
  private retryConfig: Required<RetryConfig>;
196
501
 
197
- constructor(config: { apiKey: string; baseUrl?: string; retry?: RetryConfig }) {
502
+ constructor(config: { apiKey: string; baseUrl?: string; appUrl?: string; retry?: RetryConfig }) {
198
503
  if (!config.apiKey) throw new Error('API key is required');
199
504
  this.apiKey = config.apiKey;
200
- this.baseUrl = config.baseUrl ?? 'https://api.banata.dev';
505
+ this.baseUrl = config.baseUrl ?? 'https://api.boxes.banata.dev';
506
+ this.appUrl = config.appUrl ?? 'https://boxes.banata.dev';
201
507
  this.retryConfig = {
202
508
  maxRetries: config.retry?.maxRetries ?? 3,
203
509
  baseDelayMs: config.retry?.baseDelayMs ?? 500,
@@ -294,12 +600,13 @@ export class BrowserCloud {
294
600
  /** Create a new browser session */
295
601
  async createBrowser(config: BrowserConfig = {}): Promise<BrowserSession> {
296
602
  const { timeout, waitTimeoutMs, ...rest } = config;
603
+ const requestBody: Record<string, unknown> = { ...rest };
604
+ if (waitTimeoutMs !== undefined) {
605
+ requestBody.waitTimeoutMs = waitTimeoutMs;
606
+ }
297
607
  return this.request<BrowserSession>('/v1/browsers', {
298
608
  method: 'POST',
299
- body: JSON.stringify({
300
- ...rest,
301
- waitTimeoutMs: waitTimeoutMs ?? timeout,
302
- }),
609
+ body: JSON.stringify(requestBody),
303
610
  });
304
611
  }
305
612
 
@@ -317,6 +624,60 @@ export class BrowserCloud {
317
624
  });
318
625
  }
319
626
 
627
+ /** Derive the preview websocket/start endpoints for a live browser session. */
628
+ async getPreviewConnection(sessionId: string): Promise<PreviewConnectionInfo | null> {
629
+ const session = await this.getBrowser(sessionId);
630
+ return derivePreviewConnection(session.cdpUrl);
631
+ }
632
+
633
+ async getPreviewViewerUrl(sessionId: string): Promise<string | null> {
634
+ const session = await this.getBrowser(sessionId);
635
+ return buildPreviewViewerUrl(session.cdpUrl, this.appUrl);
636
+ }
637
+
638
+ /** Start the remote browser preview backend for a live browser session. */
639
+ async startPreview(sessionId: string): Promise<PreviewCommandResponse> {
640
+ const connection = await this.getPreviewConnection(sessionId);
641
+ if (!connection) {
642
+ throw new BanataError("Browser preview is not available for this session", 409);
643
+ }
644
+ return requestPreviewEndpoint<PreviewCommandResponse>(connection.startUrl, {
645
+ method: "POST",
646
+ });
647
+ }
648
+
649
+ /** Navigate the live browser preview session. */
650
+ async navigatePreview(
651
+ sessionId: string,
652
+ url: string,
653
+ ): Promise<PreviewCommandResponse> {
654
+ const connection = await this.getPreviewConnection(sessionId);
655
+ if (!connection) {
656
+ throw new BanataError("Browser preview is not available for this session", 409);
657
+ }
658
+ return requestPreviewEndpoint<PreviewCommandResponse>(connection.navigateUrl, {
659
+ method: "POST",
660
+ headers: { "Content-Type": "application/json" },
661
+ body: JSON.stringify({ url }),
662
+ });
663
+ }
664
+
665
+ /** Resize the live browser preview session if the preview backend supports it. */
666
+ async resizePreview(
667
+ sessionId: string,
668
+ size: { width?: number; height?: number },
669
+ ): Promise<PreviewCommandResponse> {
670
+ const connection = await this.getPreviewConnection(sessionId);
671
+ if (!connection) {
672
+ throw new BanataError("Browser preview is not available for this session", 409);
673
+ }
674
+ return requestPreviewEndpoint<PreviewCommandResponse>(connection.resizeUrl, {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json" },
677
+ body: JSON.stringify(size),
678
+ });
679
+ }
680
+
320
681
  /** Wait until session is ready and return CDP URL */
321
682
  async waitForReady(
322
683
  sessionId: string,
@@ -327,7 +688,7 @@ export class BrowserCloud {
327
688
  while (Date.now() - start < timeoutMs) {
328
689
  const session = await this.getBrowser(sessionId);
329
690
 
330
- if ((session.status === 'ready' || session.status === 'active') && session.cdpUrl) {
691
+ if (isBrowserSessionOperationallyReady(session)) {
331
692
  return session.cdpUrl;
332
693
  }
333
694
  if (session.status === 'failed') {
@@ -361,32 +722,38 @@ export class BrowserCloud {
361
722
  */
362
723
  async launch(
363
724
  config: BrowserConfig = {}
364
- ): Promise<{
365
- cdpUrl: string;
366
- sessionId: string;
367
- close: () => Promise<void>;
368
- }> {
725
+ ): Promise<LaunchedBrowserSession> {
369
726
  const session = await this.createBrowser(config);
370
727
  let cdpUrl: string;
371
- try {
372
- cdpUrl = await this.waitForReady(
373
- session.id,
374
- config.timeout ?? 30_000
375
- );
376
- } catch (err) {
377
- // Clean up the orphaned session on timeout/failure
728
+ if (isBrowserSessionOperationallyReady(session)) {
729
+ cdpUrl = session.cdpUrl;
730
+ } else {
378
731
  try {
379
- await this.closeBrowser(session.id);
380
- } catch {
381
- // Best-effort cleanup — session will be cleaned by stale cron
732
+ cdpUrl = await this.waitForReady(
733
+ session.id,
734
+ config.timeout ?? 30_000
735
+ );
736
+ } catch (err) {
737
+ try {
738
+ await this.closeBrowser(session.id);
739
+ } catch {
740
+ // Best-effort cleanup — session will be cleaned by stale cron
741
+ }
742
+ throw err;
382
743
  }
383
- throw err;
384
744
  }
385
745
 
386
746
  return {
387
747
  cdpUrl,
388
748
  sessionId: session.id,
749
+ previewViewerUrl: buildPreviewViewerUrl(cdpUrl, this.appUrl),
389
750
  close: () => this.closeBrowser(session.id),
751
+ getPreviewConnection: () => this.getPreviewConnection(session.id),
752
+ getPreviewViewerUrl: () => this.getPreviewViewerUrl(session.id),
753
+ startPreview: () => this.startPreview(session.id),
754
+ navigatePreview: (url: string) => this.navigatePreview(session.id, url),
755
+ resizePreview: (size: { width?: number; height?: number }) =>
756
+ this.resizePreview(session.id, size),
390
757
  };
391
758
  }
392
759
 
@@ -456,7 +823,7 @@ export class BrowserCloud {
456
823
  export interface SandboxConfig {
457
824
  /** Sandbox runtime: bun, python, or base shell */
458
825
  runtime?: 'bun' | 'python' | 'base';
459
- /** Sandbox size: small (512MB), medium (1GB), large (2GB) */
826
+ /** Sandbox size hint. The current hosted platform normalizes all sizes to the standard 4GB sandbox shape. */
460
827
  size?: 'small' | 'medium' | 'large';
461
828
  /** Environment variables to inject into the sandbox */
462
829
  env?: Record<string, string>;
@@ -472,7 +839,7 @@ export interface SandboxConfig {
472
839
  waitUntilReady?: boolean;
473
840
  /** Max time in ms for the API to wait before falling back to a queued response */
474
841
  waitTimeoutMs?: number;
475
- /** Timeout in ms to wait for sandbox to become ready (default: 30000) */
842
+ /** Timeout in ms to wait for sandbox to become ready (default: 120000) */
476
843
  timeout?: number;
477
844
  }
478
845
 
@@ -488,6 +855,10 @@ export interface SandboxCapabilities {
488
855
  persistentProfile?: boolean;
489
856
  streamPreview?: boolean;
490
857
  humanInLoop?: boolean;
858
+ viewport?: {
859
+ width?: number;
860
+ height?: number;
861
+ };
491
862
  };
492
863
  documents?: {
493
864
  libreofficeHeadless?: boolean;
@@ -561,6 +932,17 @@ export interface SandboxBrowserPreviewState {
561
932
  updatedAt?: number;
562
933
  }
563
934
 
935
+ export interface SandboxTaskReport {
936
+ result: string;
937
+ report: string;
938
+ url?: string;
939
+ stepsCompleted?: number;
940
+ actions?: string[];
941
+ durationMs?: number;
942
+ usage?: JsonValue;
943
+ completedAt: number;
944
+ }
945
+
564
946
  export type SandboxHumanHandoffReason =
565
947
  | "mfa"
566
948
  | "captcha"
@@ -625,6 +1007,7 @@ export interface SandboxWorkerRuntimeState {
625
1007
  pairedBrowser: SandboxPairedBrowser | null;
626
1008
  artifacts: SandboxArtifacts | null;
627
1009
  humanHandoff: SandboxHumanHandoffState | null;
1010
+ lastTaskReport?: SandboxTaskReport | null;
628
1011
  }
629
1012
 
630
1013
  export interface SandboxOpencodePromptResponse {
@@ -652,6 +1035,23 @@ export interface SandboxBrowserControlResponse {
652
1035
  pairedBrowser: SandboxPairedBrowser | null;
653
1036
  }
654
1037
 
1038
+ export interface SandboxOpencodeStateResponse {
1039
+ ok: boolean;
1040
+ opencode: SandboxOpencodeState | null;
1041
+ }
1042
+
1043
+ export interface SandboxOpencodeMessagesResponse {
1044
+ ok: boolean;
1045
+ sessionId: string | null;
1046
+ messages: JsonValue;
1047
+ }
1048
+
1049
+ export interface SandboxOpencodeStreamEvent {
1050
+ type: string;
1051
+ data: JsonValue | string | null;
1052
+ raw: string;
1053
+ }
1054
+
655
1055
  export interface SandboxHumanHandoffResponse {
656
1056
  ok: boolean;
657
1057
  handoff: SandboxHumanHandoffState;
@@ -694,11 +1094,35 @@ export interface LaunchedSandbox {
694
1094
  noReply?: boolean;
695
1095
  },
696
1096
  ) => Promise<SandboxOpencodePromptResponse>;
1097
+ promptAsync: (
1098
+ prompt: string,
1099
+ options?: {
1100
+ agent?: "build" | "plan";
1101
+ sessionId?: string;
1102
+ },
1103
+ ) => Promise<SandboxOpencodePromptResponse>;
1104
+ getOpencodeState: (options?: {
1105
+ ensureSession?: boolean;
1106
+ agent?: "build" | "plan";
1107
+ }) => Promise<SandboxOpencodeStateResponse>;
1108
+ getOpencodeConnection: () => Promise<OpenCodeConnectionInfo | null>;
1109
+ listOpencodeMessages: (options?: {
1110
+ sessionId?: string;
1111
+ }) => Promise<SandboxOpencodeMessagesResponse>;
1112
+ streamOpencodeEvents: (options?: {
1113
+ sessionId?: string;
1114
+ }) => AsyncGenerator<SandboxOpencodeStreamEvent, void, void>;
697
1115
  checkpoint: () => Promise<SandboxCheckpointResponse>;
698
1116
  getRuntime: () => Promise<SandboxRuntimeState>;
699
1117
  getPreview: () => Promise<SandboxBrowserPreviewInfo>;
1118
+ getPreviewConnection: () => Promise<PreviewConnectionInfo | null>;
1119
+ getPreviewViewerUrl: () => Promise<string | null>;
1120
+ startPreview: () => Promise<PreviewCommandResponse<SandboxBrowserPreviewState>>;
1121
+ navigatePreview: (url: string) => Promise<PreviewCommandResponse<SandboxBrowserPreviewState>>;
1122
+ resizePreview: (size: { width?: number; height?: number }) => Promise<PreviewCommandResponse<SandboxBrowserPreviewState>>;
700
1123
  getHandoff: () => Promise<SandboxHumanHandoffInfo>;
701
1124
  browserPreviewUrl: string | null;
1125
+ browserPreviewViewerUrl: string | null;
702
1126
  setControl: (
703
1127
  mode: "ai" | "human" | "shared",
704
1128
  options?: { controller?: string; leaseMs?: number },
@@ -745,12 +1169,14 @@ export interface SandboxRuntimeState {
745
1169
  browserPreview: SandboxBrowserPreviewState | null;
746
1170
  humanHandoff: SandboxHumanHandoffState | null;
747
1171
  artifacts: SandboxArtifacts | null;
1172
+ lastTaskReport?: SandboxTaskReport | null;
748
1173
  runtime: SandboxWorkerRuntimeState | null;
749
1174
  }
750
1175
 
751
1176
  export interface SandboxBrowserPreviewInfo {
752
1177
  id: string;
753
1178
  browserPreviewUrl: string | null;
1179
+ browserPreviewViewerUrl: string | null;
754
1180
  previewBaseUrl: string | null;
755
1181
  browserPreview: SandboxBrowserPreviewState | null;
756
1182
  humanHandoff: SandboxHumanHandoffState | null;
@@ -778,12 +1204,14 @@ export interface SandboxHumanHandoffInfo {
778
1204
  export class BanataSandbox {
779
1205
  private apiKey: string;
780
1206
  private baseUrl: string;
1207
+ private appUrl: string;
781
1208
  private retryConfig: Required<RetryConfig>;
782
1209
 
783
- constructor(config: { apiKey: string; baseUrl?: string; retry?: RetryConfig }) {
1210
+ constructor(config: { apiKey: string; baseUrl?: string; appUrl?: string; retry?: RetryConfig }) {
784
1211
  if (!config.apiKey) throw new Error('API key is required');
785
1212
  this.apiKey = config.apiKey;
786
- this.baseUrl = config.baseUrl ?? 'https://api.banata.dev';
1213
+ this.baseUrl = config.baseUrl ?? 'https://api.boxes.banata.dev';
1214
+ this.appUrl = config.appUrl ?? 'https://boxes.banata.dev';
787
1215
  this.retryConfig = {
788
1216
  maxRetries: config.retry?.maxRetries ?? 3,
789
1217
  baseDelayMs: config.retry?.baseDelayMs ?? 500,
@@ -879,12 +1307,13 @@ export class BanataSandbox {
879
1307
  /** Create a new sandbox session */
880
1308
  async create(config: SandboxConfig = {}): Promise<SandboxSession> {
881
1309
  const { timeout, waitTimeoutMs, ...rest } = config;
1310
+ const requestBody: Record<string, unknown> = { ...rest };
1311
+ if (waitTimeoutMs !== undefined) {
1312
+ requestBody.waitTimeoutMs = waitTimeoutMs;
1313
+ }
882
1314
  return this.request<SandboxSession>('/v1/sandboxes', {
883
1315
  method: 'POST',
884
- body: JSON.stringify({
885
- ...rest,
886
- waitTimeoutMs: waitTimeoutMs ?? timeout,
887
- }),
1316
+ body: JSON.stringify(requestBody),
888
1317
  });
889
1318
  }
890
1319
 
@@ -911,14 +1340,14 @@ export class BanataSandbox {
911
1340
  /** Wait until sandbox is ready and return the session */
912
1341
  async waitForReady(
913
1342
  id: string,
914
- timeoutMs: number = 30_000
1343
+ timeoutMs: number = 120_000
915
1344
  ): Promise<SandboxSession> {
916
1345
  const start = Date.now();
917
1346
 
918
1347
  while (Date.now() - start < timeoutMs) {
919
1348
  const session = await this.get(id);
920
1349
 
921
- if (session.status === 'ready' || session.status === 'active') {
1350
+ if (isSandboxSessionOperationallyReady(session)) {
922
1351
  return session;
923
1352
  }
924
1353
  if (session.status === 'failed') {
@@ -1013,6 +1442,180 @@ export class BanataSandbox {
1013
1442
  );
1014
1443
  }
1015
1444
 
1445
+ async getOpencodeState(
1446
+ id: string,
1447
+ options: {
1448
+ ensureSession?: boolean;
1449
+ agent?: "build" | "plan";
1450
+ } = {},
1451
+ ): Promise<SandboxOpencodeStateResponse> {
1452
+ const connection = await this.getOpencodeConnection(id);
1453
+ if (connection) {
1454
+ try {
1455
+ const url = new URL(connection.stateUrl);
1456
+ if (options.ensureSession !== undefined) {
1457
+ url.searchParams.set("ensureSession", String(options.ensureSession));
1458
+ }
1459
+ if (options.agent) {
1460
+ url.searchParams.set("agent", options.agent);
1461
+ }
1462
+ return await requestPreviewEndpoint<SandboxOpencodeStateResponse>(url.toString(), {
1463
+ method: "GET",
1464
+ });
1465
+ } catch (error) {
1466
+ if (!shouldFallbackFromDirectConnection(error)) {
1467
+ throw error;
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ const query = new URLSearchParams({ id });
1473
+ if (options.ensureSession !== undefined) {
1474
+ query.set("ensureSession", String(options.ensureSession));
1475
+ }
1476
+ if (options.agent) {
1477
+ query.set("agent", options.agent);
1478
+ }
1479
+ return this.request<SandboxOpencodeStateResponse>(
1480
+ `/v1/sandboxes/opencode/state?${query.toString()}`,
1481
+ );
1482
+ }
1483
+
1484
+ async getOpencodeConnection(id: string): Promise<OpenCodeConnectionInfo | null> {
1485
+ const preview = await this.getPreview(id);
1486
+ return deriveOpenCodeConnection(
1487
+ preview.browserPreviewUrl ??
1488
+ preview.browserPreview?.publicUrl ??
1489
+ preview.pairedBrowser?.previewUrl ??
1490
+ null,
1491
+ );
1492
+ }
1493
+
1494
+ async listOpencodeMessages(
1495
+ id: string,
1496
+ options: {
1497
+ sessionId?: string;
1498
+ } = {},
1499
+ ): Promise<SandboxOpencodeMessagesResponse> {
1500
+ const connection = await this.getOpencodeConnection(id);
1501
+ if (connection) {
1502
+ try {
1503
+ const url = new URL(connection.messagesUrl);
1504
+ if (options.sessionId) {
1505
+ url.searchParams.set("opencodeSessionId", options.sessionId);
1506
+ }
1507
+ return await requestPreviewEndpoint<SandboxOpencodeMessagesResponse>(url.toString(), {
1508
+ method: "GET",
1509
+ });
1510
+ } catch (error) {
1511
+ if (!shouldFallbackFromDirectConnection(error)) {
1512
+ throw error;
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ const query = new URLSearchParams({ id });
1518
+ if (options.sessionId) {
1519
+ query.set("sessionId", options.sessionId);
1520
+ }
1521
+ return this.request<SandboxOpencodeMessagesResponse>(
1522
+ `/v1/sandboxes/opencode/messages?${query.toString()}`,
1523
+ );
1524
+ }
1525
+
1526
+ async *streamOpencodeEvents(
1527
+ id: string,
1528
+ options: {
1529
+ sessionId?: string;
1530
+ } = {},
1531
+ ): AsyncGenerator<SandboxOpencodeStreamEvent, void, void> {
1532
+ const connection = await this.getOpencodeConnection(id);
1533
+ let response: Response;
1534
+ if (connection) {
1535
+ try {
1536
+ const url = new URL(connection.eventsUrl);
1537
+ if (options.sessionId) {
1538
+ url.searchParams.set("opencodeSessionId", options.sessionId);
1539
+ }
1540
+ response = await fetch(url.toString(), {
1541
+ method: "GET",
1542
+ });
1543
+ } catch (error) {
1544
+ if (!shouldFallbackFromDirectConnection(error)) {
1545
+ throw error;
1546
+ }
1547
+ const query = new URLSearchParams({ id });
1548
+ if (options.sessionId) {
1549
+ query.set("sessionId", options.sessionId);
1550
+ }
1551
+ response = await fetch(
1552
+ `${this.baseUrl}/v1/sandboxes/opencode/events?${query.toString()}`,
1553
+ {
1554
+ method: "GET",
1555
+ headers: this.headers,
1556
+ },
1557
+ );
1558
+ }
1559
+ } else {
1560
+ const query = new URLSearchParams({ id });
1561
+ if (options.sessionId) {
1562
+ query.set("sessionId", options.sessionId);
1563
+ }
1564
+ response = await fetch(
1565
+ `${this.baseUrl}/v1/sandboxes/opencode/events?${query.toString()}`,
1566
+ {
1567
+ method: "GET",
1568
+ headers: this.headers,
1569
+ },
1570
+ );
1571
+ }
1572
+
1573
+ if (!response.ok) {
1574
+ const payload = await parseJsonResponse(response);
1575
+ const message =
1576
+ payload && typeof payload === "object" && !Array.isArray(payload) && typeof (payload as Record<string, unknown>).error === "string"
1577
+ ? String((payload as Record<string, unknown>).error)
1578
+ : typeof payload === "string"
1579
+ ? payload
1580
+ : `Failed to open OpenCode event stream (${response.status})`;
1581
+ throw new BanataError(message, response.status);
1582
+ }
1583
+
1584
+ if (!response.body) {
1585
+ throw new BanataError("OpenCode event stream did not provide a response body", 502);
1586
+ }
1587
+
1588
+ const reader = response.body.getReader();
1589
+ const decoder = new TextDecoder();
1590
+ let buffered = "";
1591
+
1592
+ try {
1593
+ while (true) {
1594
+ const { value, done } = await reader.read();
1595
+ if (done) break;
1596
+
1597
+ buffered += decoder.decode(value, { stream: true });
1598
+ const blocks = buffered.split(/\r?\n\r?\n/);
1599
+ buffered = blocks.pop() ?? "";
1600
+
1601
+ for (const block of blocks) {
1602
+ const parsed = parseSseBlock(block);
1603
+ if (parsed) {
1604
+ yield parsed;
1605
+ }
1606
+ }
1607
+ }
1608
+
1609
+ buffered += decoder.decode();
1610
+ const trailing = parseSseBlock(buffered);
1611
+ if (trailing) {
1612
+ yield trailing;
1613
+ }
1614
+ } finally {
1615
+ reader.releaseLock();
1616
+ }
1617
+ }
1618
+
1016
1619
  async prompt(
1017
1620
  id: string,
1018
1621
  prompt: string,
@@ -1022,6 +1625,25 @@ export class BanataSandbox {
1022
1625
  noReply?: boolean;
1023
1626
  } = {},
1024
1627
  ): Promise<SandboxOpencodePromptResponse> {
1628
+ const connection = await this.getOpencodeConnection(id);
1629
+ if (connection) {
1630
+ try {
1631
+ return await requestPreviewEndpoint<SandboxOpencodePromptResponse>(connection.promptUrl, {
1632
+ method: "POST",
1633
+ headers: { "Content-Type": "application/json" },
1634
+ body: JSON.stringify({
1635
+ prompt,
1636
+ agent: options.agent,
1637
+ sessionId: options.sessionId,
1638
+ }),
1639
+ });
1640
+ } catch (error) {
1641
+ if (!shouldFallbackFromDirectConnection(error)) {
1642
+ throw error;
1643
+ }
1644
+ }
1645
+ }
1646
+
1025
1647
  return this.request<SandboxOpencodePromptResponse>('/v1/sandboxes/opencode/prompt', {
1026
1648
  method: 'POST',
1027
1649
  body: JSON.stringify({
@@ -1034,6 +1656,44 @@ export class BanataSandbox {
1034
1656
  });
1035
1657
  }
1036
1658
 
1659
+ async promptAsync(
1660
+ id: string,
1661
+ prompt: string,
1662
+ options: {
1663
+ agent?: "build" | "plan";
1664
+ sessionId?: string;
1665
+ } = {},
1666
+ ): Promise<SandboxOpencodePromptResponse> {
1667
+ const connection = await this.getOpencodeConnection(id);
1668
+ if (connection) {
1669
+ try {
1670
+ return await requestPreviewEndpoint<SandboxOpencodePromptResponse>(connection.promptUrl, {
1671
+ method: "POST",
1672
+ headers: { "Content-Type": "application/json" },
1673
+ body: JSON.stringify({
1674
+ prompt,
1675
+ agent: options.agent,
1676
+ sessionId: options.sessionId,
1677
+ }),
1678
+ });
1679
+ } catch (error) {
1680
+ if (!shouldFallbackFromDirectConnection(error)) {
1681
+ throw error;
1682
+ }
1683
+ }
1684
+ }
1685
+
1686
+ return this.request<SandboxOpencodePromptResponse>('/v1/sandboxes/opencode/prompt-async', {
1687
+ method: 'POST',
1688
+ body: JSON.stringify({
1689
+ id,
1690
+ prompt,
1691
+ agent: options.agent,
1692
+ sessionId: options.sessionId,
1693
+ }),
1694
+ });
1695
+ }
1696
+
1037
1697
  async checkpoint(id: string): Promise<SandboxCheckpointResponse> {
1038
1698
  return this.request<SandboxCheckpointResponse>('/v1/sandboxes/checkpoint', {
1039
1699
  method: 'POST',
@@ -1058,9 +1718,69 @@ export class BanataSandbox {
1058
1718
  }
1059
1719
 
1060
1720
  async getPreview(id: string): Promise<SandboxBrowserPreviewInfo> {
1061
- return this.request<SandboxBrowserPreviewInfo>(
1721
+ const preview = await this.request<SandboxBrowserPreviewInfo>(
1062
1722
  `/v1/sandboxes/browser-preview?id=${encodeURIComponent(id)}`
1063
1723
  );
1724
+ return {
1725
+ ...preview,
1726
+ browserPreviewViewerUrl: buildPreviewViewerUrl(
1727
+ preview.browserPreviewUrl,
1728
+ this.appUrl,
1729
+ ),
1730
+ };
1731
+ }
1732
+
1733
+ async getPreviewConnection(id: string): Promise<PreviewConnectionInfo | null> {
1734
+ const preview = await this.getPreview(id);
1735
+ return derivePreviewConnection(preview.browserPreviewUrl);
1736
+ }
1737
+
1738
+ async getPreviewViewerUrl(id: string): Promise<string | null> {
1739
+ const preview = await this.getPreview(id);
1740
+ return (
1741
+ preview.browserPreviewViewerUrl ??
1742
+ buildPreviewViewerUrl(preview.browserPreviewUrl, this.appUrl)
1743
+ );
1744
+ }
1745
+
1746
+ async startPreview(id: string): Promise<PreviewCommandResponse<SandboxBrowserPreviewState>> {
1747
+ const connection = await this.getPreviewConnection(id);
1748
+ if (!connection) {
1749
+ throw new BanataError("Sandbox browser preview is not available", 409);
1750
+ }
1751
+ return requestPreviewEndpoint<PreviewCommandResponse<SandboxBrowserPreviewState>>(connection.startUrl, {
1752
+ method: "POST",
1753
+ });
1754
+ }
1755
+
1756
+ async navigatePreview(
1757
+ id: string,
1758
+ url: string,
1759
+ ): Promise<PreviewCommandResponse<SandboxBrowserPreviewState>> {
1760
+ const connection = await this.getPreviewConnection(id);
1761
+ if (!connection) {
1762
+ throw new BanataError("Sandbox browser preview is not available", 409);
1763
+ }
1764
+ return requestPreviewEndpoint<PreviewCommandResponse<SandboxBrowserPreviewState>>(connection.navigateUrl, {
1765
+ method: "POST",
1766
+ headers: { "Content-Type": "application/json" },
1767
+ body: JSON.stringify({ url }),
1768
+ });
1769
+ }
1770
+
1771
+ async resizePreview(
1772
+ id: string,
1773
+ size: { width?: number; height?: number },
1774
+ ): Promise<PreviewCommandResponse<SandboxBrowserPreviewState>> {
1775
+ const connection = await this.getPreviewConnection(id);
1776
+ if (!connection) {
1777
+ throw new BanataError("Sandbox browser preview is not available", 409);
1778
+ }
1779
+ return requestPreviewEndpoint<PreviewCommandResponse<SandboxBrowserPreviewState>>(connection.resizeUrl, {
1780
+ method: "POST",
1781
+ headers: { "Content-Type": "application/json" },
1782
+ body: JSON.stringify(size),
1783
+ });
1064
1784
  }
1065
1785
 
1066
1786
  async getHandoff(id: string): Promise<SandboxHumanHandoffInfo> {
@@ -1160,40 +1880,65 @@ export class BanataSandbox {
1160
1880
  const created = await this.create(config);
1161
1881
  let session: SandboxSession;
1162
1882
 
1163
- try {
1164
- session = await this.waitForReady(
1165
- created.id,
1166
- config.timeout ?? 30_000
1167
- );
1168
- } catch (err) {
1169
- // Clean up the orphaned sandbox on timeout/failure
1883
+ if (isSandboxSessionOperationallyReady(created)) {
1884
+ session = created;
1885
+ } else {
1170
1886
  try {
1171
- await this.kill(created.id);
1172
- } catch {
1173
- // Best-effort cleanup
1887
+ session = await this.waitForReady(
1888
+ created.id,
1889
+ config.timeout ?? 120_000
1890
+ );
1891
+ } catch (err) {
1892
+ // Clean up the orphaned sandbox on timeout/failure
1893
+ try {
1894
+ await this.kill(created.id);
1895
+ } catch {
1896
+ // Best-effort cleanup
1897
+ }
1898
+ throw err;
1174
1899
  }
1175
- throw err;
1176
1900
  }
1177
1901
 
1178
- const sessionId = session.id;
1179
- const terminalUrl = session.terminalUrl ?? '';
1180
- const browserPreviewUrl =
1181
- session.browserPreview?.publicUrl ??
1182
- session.pairedBrowser?.previewUrl ??
1183
- null;
1902
+ const sessionId = session.id;
1903
+ const terminalUrl = session.terminalUrl ?? '';
1904
+ const browserPreviewUrl =
1905
+ session.browserPreview?.publicUrl ??
1906
+ session.pairedBrowser?.previewUrl ??
1907
+ null;
1908
+ const browserPreviewViewerUrl = buildPreviewViewerUrl(
1909
+ browserPreviewUrl,
1910
+ this.appUrl,
1911
+ );
1184
1912
 
1185
1913
  return {
1186
1914
  sessionId,
1187
1915
  terminalUrl,
1188
1916
  browserPreviewUrl,
1917
+ browserPreviewViewerUrl,
1189
1918
  exec: (command: string, args?: string[]) =>
1190
1919
  this.exec(sessionId, command, args),
1191
1920
  runCode: (code: string) =>
1192
1921
  this.runCode(sessionId, code),
1193
1922
  prompt: (prompt, options) => this.prompt(sessionId, prompt, options),
1923
+ promptAsync: (prompt, options) =>
1924
+ this.promptAsync(sessionId, prompt, options),
1925
+ getOpencodeState: (options) =>
1926
+ this.getOpencodeState(sessionId, options),
1927
+ getOpencodeConnection: () =>
1928
+ this.getOpencodeConnection(sessionId),
1929
+ listOpencodeMessages: (options) =>
1930
+ this.listOpencodeMessages(sessionId, options),
1931
+ streamOpencodeEvents: (options) =>
1932
+ this.streamOpencodeEvents(sessionId, options),
1194
1933
  checkpoint: () => this.checkpoint(sessionId),
1195
1934
  getRuntime: () => this.getRuntime(sessionId),
1196
1935
  getPreview: () => this.getPreview(sessionId),
1936
+ getPreviewConnection: () => this.getPreviewConnection(sessionId),
1937
+ getPreviewViewerUrl: () => this.getPreviewViewerUrl(sessionId),
1938
+ startPreview: () => this.startPreview(sessionId),
1939
+ navigatePreview: (url: string) => this.navigatePreview(sessionId, url),
1940
+ resizePreview: (size: { width?: number; height?: number }) =>
1941
+ this.resizePreview(sessionId, size),
1197
1942
  getHandoff: () => this.getHandoff(sessionId),
1198
1943
  setControl: (mode, options) => this.setControl(sessionId, mode, options),
1199
1944
  takeControl: (options) => this.setControl(sessionId, "human", options),