@opengeni/sdk 0.13.0 → 0.20.0

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/client.ts CHANGED
@@ -1,9 +1,13 @@
1
- import { OpenGeniApiError } from "./errors";
1
+ import { OpenGeniApiContractMismatchError, OpenGeniApiError } from "./errors";
2
2
  import {
3
3
  streamSessionEvents,
4
4
  type SessionEventStreamTransport,
5
5
  type StreamSessionEventsOptions,
6
6
  } from "./stream";
7
+ import {
8
+ streamWorkspaceControlEvents,
9
+ type WorkspaceControlStreamTransport,
10
+ } from "./workspace-control-stream";
7
11
  import type {
8
12
  AccessContext,
9
13
  AddWorkspaceMemberRequest,
@@ -42,6 +46,7 @@ import type {
42
46
  CreateKnowledgeMemoryRequest,
43
47
  CreateScheduledTaskRequest,
44
48
  CreateSessionRequest,
49
+ CreateSessionResponse,
45
50
  CreateVariableSetRequest,
46
51
  CreateRigRequest,
47
52
  CreateWorkspaceRequest,
@@ -85,14 +90,25 @@ import type {
85
90
  SessionListResponse,
86
91
  UpdateSessionPinRequest,
87
92
  SessionEvent,
93
+ SessionEventListOptions,
94
+ SessionEventPage,
88
95
  SessionGoal,
96
+ SessionHumanInputRequest,
89
97
  SessionLineageResponse,
90
98
  SessionMcpCredentialUpdateInput,
91
99
  SessionQueueSnapshot,
92
100
  SessionQueueMutationResponse,
101
+ ComposerDraft,
102
+ DeleteSessionQueueItemRequest,
103
+ EditSessionQueueItemRequest,
104
+ MoveSessionQueueItemRequest,
105
+ SaveComposerDraftRequest,
106
+ SteerSessionQueueItemRequest,
93
107
  SessionControlResponse,
94
108
  WorkspaceInferenceControlResponse,
109
+ WorkspaceControlEvent,
95
110
  SessionTurn,
111
+ SubmitHumanInputResponseRequest,
96
112
  // Stream surfacing (Phase 5): capability negotiation + viewer lifecycle + config.
97
113
  SessionCapabilities,
98
114
  AttachViewerRequest,
@@ -122,7 +138,7 @@ import type {
122
138
  GitLogResponse,
123
139
  GitShowRequest,
124
140
  GitShowResponse,
125
- // Workbench v2 turn-end capture reads (M2, dossier §10.3).
141
+ // Workbench v2 turn-end capture reads (M2).
126
142
  GetWorkspaceCaptureResponse,
127
143
  GetWorkspaceCaptureFileResponse,
128
144
  TerminalExecRequest,
@@ -161,9 +177,17 @@ import type {
161
177
  OAuthStartRequest,
162
178
  OAuthStartResponse,
163
179
  } from "./types";
180
+ import { OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION } from "./types";
164
181
 
165
182
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
166
183
 
184
+ export type WorkspaceControlEventPage = {
185
+ events: WorkspaceControlEvent[];
186
+ bytes: number;
187
+ truncated: boolean;
188
+ nextAfter: number | null;
189
+ };
190
+
167
191
  export type OpenGeniClientOptions = {
168
192
  /** Base URL of the OpenGeni API, e.g. `https://api.example.com`. */
169
193
  baseUrl: string;
@@ -175,15 +199,22 @@ export type OpenGeniClientOptions = {
175
199
  fetch?: FetchLike;
176
200
  };
177
201
 
202
+ /** Per-request cancellation for identity-scoped, side-effect-free reads. */
203
+ export type OpenGeniRequestOptions = {
204
+ signal?: AbortSignal | undefined;
205
+ };
206
+
178
207
  export type SendMessageInput = {
179
208
  text: string;
209
+ /** System instructions scoped to this exact turn; never visible timeline text. */
210
+ turnInstructions?: string;
180
211
  resources?: ResourceRef[];
181
212
  tools?: ToolRef[];
182
213
  model?: string;
183
214
  reasoningEffort?: ReasoningEffort;
184
215
  clientEventId?: string;
185
- expectedControlGeneration?: number;
186
- expectedWorkspaceInferenceGeneration?: number;
216
+ controlEtag?: string;
217
+ expectedDraftRevision?: number;
187
218
  mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
188
219
  };
189
220
 
@@ -213,8 +244,11 @@ export class OpenGeniClient {
213
244
 
214
245
  // --- Session lifecycle ---------------------------------------------------
215
246
 
216
- async createSession(workspaceId: string, request: CreateSessionRequest): Promise<Session> {
217
- return await this.requestJson<Session>(
247
+ async createSession(
248
+ workspaceId: string,
249
+ request: CreateSessionRequest,
250
+ ): Promise<CreateSessionResponse> {
251
+ return await this.requestJson<CreateSessionResponse>(
218
252
  "POST",
219
253
  `/v1/workspaces/${workspaceId}/sessions`,
220
254
  request,
@@ -276,7 +310,7 @@ export class OpenGeniClient {
276
310
  search?: string;
277
311
  } = {},
278
312
  ): Promise<SessionListResponse> {
279
- const response = await this.requestJson<SessionListResponse | Session[]>(
313
+ return await this.requestJson<SessionListResponse>(
280
314
  "GET",
281
315
  `/v1/workspaces/${workspaceId}/sessions`,
282
316
  undefined,
@@ -294,23 +328,6 @@ export class OpenGeniClient {
294
328
  : {}),
295
329
  },
296
330
  );
297
- if (Array.isArray(response)) {
298
- // Rolling/same-major compatibility: an older API ignores `view=page` and
299
- // returns the historical array. That is an honest one-page projection;
300
- // never pretend it honored a cursor supplied directly by a caller.
301
- if (options.cursor) {
302
- throw new Error("The connected OpenGeni API does not support stable session-page cursors");
303
- }
304
- // Older APIs ignore unknown query parameters. Treating their unfiltered
305
- // array as a successful search would be worse than an explicit rolling-
306
- // upgrade error (and client-side filtering cannot recover matches beyond
307
- // the old endpoint's bounded first page).
308
- if (options.search?.trim()) {
309
- throw new Error("The connected OpenGeni API does not support session search");
310
- }
311
- return { pinned: [], sessions: response, nextCursor: null };
312
- }
313
- return response;
314
331
  }
315
332
 
316
333
  /** Set this authenticated member's personal workspace pin for a session. */
@@ -358,7 +375,7 @@ export class OpenGeniClient {
358
375
  */
359
376
  async listMachines(
360
377
  workspaceId: string,
361
- options: { sessionId?: string } = {},
378
+ options: { sessionId?: string; signal?: AbortSignal } = {},
362
379
  ): Promise<MachinesResponse> {
363
380
  return await this.requestJson<MachinesResponse>(
364
381
  "GET",
@@ -367,6 +384,7 @@ export class OpenGeniClient {
367
384
  {
368
385
  ...(options.sessionId !== undefined ? { sessionId: options.sessionId } : {}),
369
386
  },
387
+ { signal: options.signal },
370
388
  );
371
389
  }
372
390
 
@@ -498,27 +516,101 @@ export class OpenGeniClient {
498
516
  // --- Events: replay, send, stream ----------------------------------------
499
517
 
500
518
  /**
501
- * Replay durable events by sequence, ascending. `before` is exclusive and
502
- * returns the newest matching window. With `compact`, consecutive delta runs
503
- * may be coalesced; `payload.coalescedUntil` carries the run's last sequence
504
- * for resume cursors.
519
+ * Return the events from one bounded page. With no cursor, this uses the safe
520
+ * semantic monitoring tail; pass explicit forensic options and a cursor for
521
+ * retained audit replay. Use `listEventPage` when projection, coverage, or
522
+ * resume-cursor facts are required.
505
523
  */
506
524
  async listEvents(
507
525
  workspaceId: string,
508
526
  sessionId: string,
509
- options: { after?: number; before?: number; limit?: number; compact?: boolean } = {},
527
+ options: SessionEventListOptions = {},
510
528
  ): Promise<SessionEvent[]> {
511
- return await this.requestJson<SessionEvent[]>(
512
- "GET",
513
- `/v1/workspaces/${workspaceId}/sessions/${sessionId}/events`,
514
- undefined,
515
- {
529
+ return (await this.listEventPage(workspaceId, sessionId, options)).events;
530
+ }
531
+
532
+ /** Bounded durable/monitoring page plus exact projection and cursor facts. */
533
+ async listEventPage(
534
+ workspaceId: string,
535
+ sessionId: string,
536
+ options: SessionEventListOptions = {},
537
+ ): Promise<SessionEventPage> {
538
+ if (
539
+ options.latest &&
540
+ ["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some((name) =>
541
+ Object.prototype.hasOwnProperty.call(options, name),
542
+ )
543
+ ) {
544
+ throw new TypeError("latest cannot be combined with event filters");
545
+ }
546
+ const response = await this.fetchImpl(
547
+ this.url(`/v1/workspaces/${workspaceId}/sessions/${sessionId}/events`, {
516
548
  ...(options.after !== undefined ? { after: String(options.after) } : {}),
517
549
  ...(options.before !== undefined ? { before: String(options.before) } : {}),
518
550
  ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
519
551
  ...(options.compact ? { compact: "1" } : {}),
552
+ ...(options.mode ? { mode: options.mode } : {}),
553
+ ...(options.direction ? { direction: options.direction } : {}),
554
+ ...(options.payloadMode ? { payloadMode: options.payloadMode } : {}),
555
+ ...(options.includeTypes?.length ? { includeTypes: options.includeTypes.join(",") } : {}),
556
+ ...(options.excludeTypes?.length ? { excludeTypes: options.excludeTypes.join(",") } : {}),
557
+ ...(options.includeClasses?.length
558
+ ? { includeClasses: options.includeClasses.join(",") }
559
+ : {}),
560
+ ...(options.excludeClasses?.length
561
+ ? { excludeClasses: options.excludeClasses.join(",") }
562
+ : {}),
563
+ ...(options.latest ? { latest: options.latest } : {}),
564
+ }),
565
+ {
566
+ method: "GET",
567
+ headers: { ...this.headers(), Accept: "application/json" },
520
568
  },
521
569
  );
570
+ assertApiContractResponse(response);
571
+ if (!response.ok) throw new OpenGeniApiError(response.status, await safeText(response));
572
+ const events = (await response.json()) as SessionEvent[];
573
+ const integerHeader = (name: string): number | null => {
574
+ const raw = response.headers.get(name);
575
+ if (raw === null) return null;
576
+ const value = Number(raw);
577
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
578
+ };
579
+ const mode =
580
+ response.headers.get("X-OpenGeni-Event-Mode") === "forensic" ? "forensic" : "monitoring";
581
+ const direction =
582
+ response.headers.get("X-OpenGeni-Event-Direction") === "after" ? "after" : "before";
583
+ const payloadHeader = response.headers.get("X-OpenGeni-Payload-Mode");
584
+ const payloadMode =
585
+ payloadHeader === "none" || payloadHeader === "full" ? payloadHeader : "summary";
586
+ const first = integerHeader("X-OpenGeni-Covered-First");
587
+ const last = integerHeader("X-OpenGeni-Covered-Last");
588
+ const bytes =
589
+ integerHeader("X-OpenGeni-Page-Bytes") ??
590
+ new TextEncoder().encode(JSON.stringify(events)).byteLength;
591
+ const maxBytes = integerHeader("X-OpenGeni-Page-Max-Bytes") ?? 1024 * 1024;
592
+ const truncatedByHeader = response.headers.get("X-OpenGeni-Truncated-By");
593
+ const truncatedBy =
594
+ truncatedByHeader === "count" ||
595
+ truncatedByHeader === "bytes" ||
596
+ truncatedByHeader === "http_bytes"
597
+ ? truncatedByHeader
598
+ : null;
599
+ return {
600
+ events,
601
+ mode,
602
+ payloadMode,
603
+ direction,
604
+ bytes,
605
+ maxBytes,
606
+ truncated: response.headers.get("X-OpenGeni-Page-Truncated") === "true",
607
+ hasMore: response.headers.get("X-OpenGeni-Has-More") === "true",
608
+ truncatedBy,
609
+ coveredSequence: first === null || last === null ? null : { first, last },
610
+ nextAfter: integerHeader("X-OpenGeni-Next-After"),
611
+ nextBefore: integerHeader("X-OpenGeni-Next-Before"),
612
+ forensicExact: response.headers.get("X-OpenGeni-Forensic-Exact") === "true",
613
+ };
522
614
  }
523
615
 
524
616
  /** POST a user/control event to the session. Returns the accepted event. */
@@ -551,14 +643,14 @@ export class OpenGeniClient {
551
643
  async pauseSession(
552
644
  workspaceId: string,
553
645
  sessionId: string,
554
- options: { reason?: string; clientEventId?: string } = {},
555
- ): Promise<SessionEvent> {
556
- return (
557
- await this.controlSession(workspaceId, sessionId, {
558
- mode: "pause",
559
- ...options,
560
- })
561
- ).event;
646
+ options: { reason?: string; clientEventId?: string; expectedControlEtag?: string } = {},
647
+ ): Promise<SessionControlResponse> {
648
+ return await this.controlSession(workspaceId, sessionId, {
649
+ action: "pause",
650
+ clientEventId: options.clientEventId ?? crypto.randomUUID(),
651
+ ...(options.reason ? { reason: options.reason } : {}),
652
+ ...(options.expectedControlEtag ? { expectedControlEtag: options.expectedControlEtag } : {}),
653
+ });
562
654
  }
563
655
 
564
656
  async sendApprovalDecision(
@@ -579,6 +671,47 @@ export class OpenGeniClient {
579
671
  });
580
672
  }
581
673
 
674
+ async listHumanInputRequests(
675
+ workspaceId: string,
676
+ sessionId: string,
677
+ options: {
678
+ status?: SessionHumanInputRequest["status"];
679
+ } = {},
680
+ ): Promise<SessionHumanInputRequest[]> {
681
+ const result = await this.requestJson<{ requests: SessionHumanInputRequest[] }>(
682
+ "GET",
683
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/human-input-requests`,
684
+ undefined,
685
+ options.status ? { status: options.status } : undefined,
686
+ );
687
+ return result.requests;
688
+ }
689
+
690
+ async getHumanInputRequest(
691
+ workspaceId: string,
692
+ sessionId: string,
693
+ requestId: string,
694
+ ): Promise<SessionHumanInputRequest> {
695
+ return await this.requestJson<SessionHumanInputRequest>(
696
+ "GET",
697
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/human-input-requests/${requestId}`,
698
+ );
699
+ }
700
+
701
+ async submitHumanInputResponse(
702
+ workspaceId: string,
703
+ sessionId: string,
704
+ requestId: string,
705
+ response: SubmitHumanInputResponseRequest,
706
+ options: { clientEventId?: string } = {},
707
+ ): Promise<SessionEvent> {
708
+ return await this.sendEvent(workspaceId, sessionId, {
709
+ type: "user.humanInputResponse",
710
+ ...(options.clientEventId ? { clientEventId: options.clientEventId } : {}),
711
+ payload: { requestId, response },
712
+ });
713
+ }
714
+
582
715
  /**
583
716
  * Live-stream a session's events with automatic reconnect, resume from the
584
717
  * last seen sequence, gap backfill, and duplicate suppression. See
@@ -619,6 +752,7 @@ export class OpenGeniClient {
619
752
  headers: { ...this.headers(), Accept: "text/event-stream" },
620
753
  ...(options.signal ? { signal: options.signal } : {}),
621
754
  });
755
+ assertApiContractResponse(response);
622
756
  if (!response.ok) {
623
757
  throw new OpenGeniApiError(response.status, await safeText(response));
624
758
  }
@@ -637,15 +771,73 @@ export class OpenGeniClient {
637
771
  );
638
772
  }
639
773
 
640
- async cancelQueueItem(
774
+ async moveQueueItem(
775
+ workspaceId: string,
776
+ sessionId: string,
777
+ turnId: string,
778
+ request: MoveSessionQueueItemRequest,
779
+ ): Promise<SessionQueueMutationResponse> {
780
+ return await this.requestJson<SessionQueueMutationResponse>(
781
+ "POST",
782
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/queue/${turnId}/move`,
783
+ request,
784
+ );
785
+ }
786
+
787
+ async editQueueItem(
788
+ workspaceId: string,
789
+ sessionId: string,
790
+ turnId: string,
791
+ request: EditSessionQueueItemRequest,
792
+ ): Promise<SessionQueueMutationResponse> {
793
+ return await this.requestJson<SessionQueueMutationResponse>(
794
+ "POST",
795
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/queue/${turnId}/edit`,
796
+ request,
797
+ );
798
+ }
799
+
800
+ async steerQueueItem(
801
+ workspaceId: string,
802
+ sessionId: string,
803
+ turnId: string,
804
+ request: SteerSessionQueueItemRequest,
805
+ ): Promise<SessionQueueMutationResponse> {
806
+ return await this.requestJson<SessionQueueMutationResponse>(
807
+ "POST",
808
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/queue/${turnId}/steer`,
809
+ request,
810
+ );
811
+ }
812
+
813
+ async deleteQueueItem(
641
814
  workspaceId: string,
642
815
  sessionId: string,
643
816
  turnId: string,
644
- request: { expectedQueueVersion: number; expectedItemVersion: number; reason?: string },
817
+ request: DeleteSessionQueueItemRequest,
645
818
  ): Promise<SessionQueueMutationResponse> {
646
819
  return await this.requestJson<SessionQueueMutationResponse>(
647
820
  "POST",
648
- `/v1/workspaces/${workspaceId}/sessions/${sessionId}/queue/${turnId}/cancel`,
821
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/queue/${turnId}/delete`,
822
+ request,
823
+ );
824
+ }
825
+
826
+ async getComposerDraft(workspaceId: string, sessionId: string): Promise<ComposerDraft> {
827
+ return await this.requestJson<ComposerDraft>(
828
+ "GET",
829
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/composer-draft`,
830
+ );
831
+ }
832
+
833
+ async saveComposerDraft(
834
+ workspaceId: string,
835
+ sessionId: string,
836
+ request: SaveComposerDraftRequest,
837
+ ): Promise<ComposerDraft> {
838
+ return await this.requestJson<ComposerDraft>(
839
+ "PUT",
840
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/composer-draft`,
649
841
  request,
650
842
  );
651
843
  }
@@ -654,12 +846,10 @@ export class OpenGeniClient {
654
846
  workspaceId: string,
655
847
  sessionId: string,
656
848
  request: {
657
- mode: "pause" | "resume";
849
+ action: "pause" | "resume";
658
850
  reason?: string;
659
- clientEventId?: string;
660
- expectedControlState?: "active" | "paused";
661
- expectedControlGeneration?: number;
662
- expectedWorkspaceInferenceGeneration?: number;
851
+ clientEventId: string;
852
+ expectedControlEtag?: string;
663
853
  },
664
854
  ): Promise<SessionControlResponse> {
665
855
  return await this.requestJson<SessionControlResponse>(
@@ -672,20 +862,23 @@ export class OpenGeniClient {
672
862
  async resumeSession(
673
863
  workspaceId: string,
674
864
  sessionId: string,
675
- options: { reason?: string; clientEventId?: string } = {},
865
+ options: { reason?: string; clientEventId?: string; expectedControlEtag?: string } = {},
676
866
  ): Promise<SessionControlResponse> {
677
- return await this.controlSession(workspaceId, sessionId, { mode: "resume", ...options });
867
+ return await this.controlSession(workspaceId, sessionId, {
868
+ action: "resume",
869
+ clientEventId: options.clientEventId ?? crypto.randomUUID(),
870
+ ...(options.reason ? { reason: options.reason } : {}),
871
+ ...(options.expectedControlEtag ? { expectedControlEtag: options.expectedControlEtag } : {}),
872
+ });
678
873
  }
679
874
 
680
875
  async setWorkspaceInferenceState(
681
876
  workspaceId: string,
682
877
  request: {
683
- state: "active" | "paused";
684
- reason: string;
878
+ action: "pause" | "resume";
879
+ reason?: string;
685
880
  clientEventId: string;
686
- expectedState: "active" | "paused";
687
- expectedGeneration: number;
688
- exceptSessionIds?: string[];
881
+ expectedRevision?: number;
689
882
  },
690
883
  ): Promise<WorkspaceInferenceControlResponse> {
691
884
  return await this.requestJson<WorkspaceInferenceControlResponse>(
@@ -695,16 +888,88 @@ export class OpenGeniClient {
695
888
  );
696
889
  }
697
890
 
698
- /** Cancel a queued turn before it is claimed. Returns the cancelled turn. */
699
- async deleteQueuedTurn(
891
+ async listWorkspaceControlEvents(
700
892
  workspaceId: string,
701
- sessionId: string,
702
- turnId: string,
703
- ): Promise<SessionTurn> {
704
- return await this.requestJson<SessionTurn>(
705
- "DELETE",
706
- `/v1/workspaces/${workspaceId}/sessions/${sessionId}/turns/${turnId}`,
893
+ options: { after?: number; limit?: number } = {},
894
+ ): Promise<WorkspaceControlEvent[]> {
895
+ return (await this.listWorkspaceControlEventPage(workspaceId, options)).events;
896
+ }
897
+
898
+ /** Count/byte-bounded page plus an explicit continuation cursor. */
899
+ async listWorkspaceControlEventPage(
900
+ workspaceId: string,
901
+ options: { after?: number; limit?: number } = {},
902
+ ): Promise<WorkspaceControlEventPage> {
903
+ const response = await this.fetchImpl(
904
+ this.url(`/v1/workspaces/${workspaceId}/control-events`, {
905
+ ...(options.after !== undefined ? { after: String(options.after) } : {}),
906
+ ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
907
+ }),
908
+ {
909
+ method: "GET",
910
+ headers: { ...this.headers(), Accept: "application/json" },
911
+ },
707
912
  );
913
+ assertApiContractResponse(response);
914
+ if (!response.ok) {
915
+ throw new OpenGeniApiError(response.status, await safeText(response));
916
+ }
917
+ const events = (await response.json()) as WorkspaceControlEvent[];
918
+ const bytesHeader = response.headers.get("X-OpenGeni-Page-Bytes");
919
+ const nextHeader = response.headers.get("X-OpenGeni-Next-After");
920
+ const parsedBytes = bytesHeader === null ? Number.NaN : Number(bytesHeader);
921
+ const parsedNext = nextHeader === null ? null : Number(nextHeader);
922
+ return {
923
+ events,
924
+ bytes:
925
+ Number.isSafeInteger(parsedBytes) && parsedBytes >= 0
926
+ ? parsedBytes
927
+ : new TextEncoder().encode(JSON.stringify(events)).byteLength,
928
+ truncated: response.headers.get("X-OpenGeni-Page-Truncated") === "true",
929
+ nextAfter:
930
+ parsedNext !== null && Number.isSafeInteger(parsedNext) && parsedNext >= 0
931
+ ? parsedNext
932
+ : null,
933
+ };
934
+ }
935
+
936
+ streamWorkspaceControlEvents(
937
+ workspaceId: string,
938
+ options: StreamSessionEventsOptions = {},
939
+ ): AsyncGenerator<WorkspaceControlEvent, void, void> {
940
+ return streamWorkspaceControlEvents(this.workspaceControlStreamTransport(workspaceId), options);
941
+ }
942
+
943
+ workspaceControlStreamTransport(workspaceId: string): WorkspaceControlStreamTransport {
944
+ return {
945
+ openStream: async (after, signal) =>
946
+ await this.openWorkspaceControlEventStream(workspaceId, {
947
+ after,
948
+ ...(signal ? { signal } : {}),
949
+ }),
950
+ };
951
+ }
952
+
953
+ async openWorkspaceControlEventStream(
954
+ workspaceId: string,
955
+ options: { after?: number; signal?: AbortSignal } = {},
956
+ ): Promise<ReadableStream<Uint8Array>> {
957
+ const response = await this.fetchImpl(
958
+ this.url(`/v1/workspaces/${workspaceId}/control-events/stream`, {
959
+ after: String(options.after ?? 0),
960
+ }),
961
+ {
962
+ method: "GET",
963
+ headers: { ...this.headers(), Accept: "text/event-stream" },
964
+ ...(options.signal ? { signal: options.signal } : {}),
965
+ },
966
+ );
967
+ assertApiContractResponse(response);
968
+ if (!response.ok) throw new OpenGeniApiError(response.status, await safeText(response));
969
+ if (!response.body) {
970
+ throw new OpenGeniApiError(response.status, "SSE response did not include a readable body");
971
+ }
972
+ return response.body;
708
973
  }
709
974
 
710
975
  /**
@@ -806,11 +1071,14 @@ export class OpenGeniClient {
806
1071
  workspaceId: string,
807
1072
  sessionId: string,
808
1073
  request: FsListRequest = {},
1074
+ options: OpenGeniRequestOptions = {},
809
1075
  ): Promise<FsListResponse> {
810
1076
  return await this.requestJson<FsListResponse>(
811
1077
  "POST",
812
1078
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/list`,
813
1079
  request,
1080
+ {},
1081
+ options,
814
1082
  );
815
1083
  }
816
1084
 
@@ -819,11 +1087,14 @@ export class OpenGeniClient {
819
1087
  workspaceId: string,
820
1088
  sessionId: string,
821
1089
  request: FsReadRequest,
1090
+ options: OpenGeniRequestOptions = {},
822
1091
  ): Promise<FsReadResponse> {
823
1092
  return await this.requestJson<FsReadResponse>(
824
1093
  "POST",
825
1094
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/fs/read`,
826
1095
  request,
1096
+ {},
1097
+ options,
827
1098
  );
828
1099
  }
829
1100
 
@@ -884,11 +1155,14 @@ export class OpenGeniClient {
884
1155
  workspaceId: string,
885
1156
  sessionId: string,
886
1157
  request: GitStatusRequest = {},
1158
+ options: OpenGeniRequestOptions = {},
887
1159
  ): Promise<GitStatusResponse> {
888
1160
  return await this.requestJson<GitStatusResponse>(
889
1161
  "POST",
890
1162
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/status`,
891
1163
  request,
1164
+ {},
1165
+ options,
892
1166
  );
893
1167
  }
894
1168
 
@@ -897,11 +1171,14 @@ export class OpenGeniClient {
897
1171
  workspaceId: string,
898
1172
  sessionId: string,
899
1173
  request: GitDiffRequest = {},
1174
+ options: OpenGeniRequestOptions = {},
900
1175
  ): Promise<GitDiffResponse> {
901
1176
  return await this.requestJson<GitDiffResponse>(
902
1177
  "POST",
903
1178
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/git/diff`,
904
1179
  request,
1180
+ {},
1181
+ options,
905
1182
  );
906
1183
  }
907
1184
 
@@ -938,10 +1215,14 @@ export class OpenGeniClient {
938
1215
  async getWorkspaceCapture(
939
1216
  workspaceId: string,
940
1217
  sessionId: string,
1218
+ options: OpenGeniRequestOptions = {},
941
1219
  ): Promise<GetWorkspaceCaptureResponse> {
942
1220
  return await this.requestJson<GetWorkspaceCaptureResponse>(
943
1221
  "GET",
944
1222
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/workspace/capture`,
1223
+ undefined,
1224
+ {},
1225
+ options,
945
1226
  );
946
1227
  }
947
1228
 
@@ -953,6 +1234,7 @@ export class OpenGeniClient {
953
1234
  sessionId: string,
954
1235
  path: string,
955
1236
  revision?: number,
1237
+ options: OpenGeniRequestOptions = {},
956
1238
  ): Promise<GetWorkspaceCaptureFileResponse> {
957
1239
  const query: Record<string, string> = { path };
958
1240
  if (revision !== undefined) query.revision = String(revision);
@@ -961,6 +1243,7 @@ export class OpenGeniClient {
961
1243
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/workspace/capture/file`,
962
1244
  undefined,
963
1245
  query,
1246
+ options,
964
1247
  );
965
1248
  }
966
1249
 
@@ -1045,10 +1328,14 @@ export class OpenGeniClient {
1045
1328
  async getStreamCapabilities(
1046
1329
  workspaceId: string,
1047
1330
  sessionId: string,
1331
+ options: OpenGeniRequestOptions = {},
1048
1332
  ): Promise<SessionCapabilities> {
1049
1333
  return await this.requestJson<SessionCapabilities>(
1050
1334
  "GET",
1051
1335
  `/v1/workspaces/${workspaceId}/sessions/${sessionId}/stream-capabilities`,
1336
+ undefined,
1337
+ {},
1338
+ options,
1052
1339
  );
1053
1340
  }
1054
1341
 
@@ -1121,7 +1408,14 @@ export class OpenGeniClient {
1121
1408
  * knowledge of the host setup; safe to call before any auth is established.
1122
1409
  */
1123
1410
  async getClientConfig(): Promise<ClientConfig> {
1124
- return await this.requestJson<ClientConfig>("GET", "/v1/config/client");
1411
+ const config = await this.requestJson<ClientConfig>("GET", "/v1/config/client");
1412
+ if (config.apiContractRevision !== OPENGENI_API_CONTRACT_REVISION) {
1413
+ throw new OpenGeniApiContractMismatchError(
1414
+ OPENGENI_API_CONTRACT_REVISION,
1415
+ String(config.apiContractRevision || "(missing)"),
1416
+ );
1417
+ }
1418
+ return config;
1125
1419
  }
1126
1420
 
1127
1421
  /** The caller's access context: subject, account + workspace grants, defaults. */
@@ -1944,15 +2238,14 @@ export class OpenGeniClient {
1944
2238
 
1945
2239
  // --- GitHub ----------------------------------------------------------------------------------
1946
2240
 
1947
- /** GitHub App configuration status + a signed install URL when configured. */
2241
+ /** GitHub App configuration status; install/link URLs are null while new binding is disabled. */
1948
2242
  async getGitHubApp(workspaceId: string): Promise<GitHubAppInfo> {
1949
2243
  return await this.requestJson<GitHubAppInfo>("GET", `/v1/workspaces/${workspaceId}/github/app`);
1950
2244
  }
1951
2245
 
1952
2246
  /**
1953
- * Browser entry point that plants the CSRF cookie and forwards to GitHub's
1954
- * install page. Open this in a browser (it redirects); `state` comes from
1955
- * `getGitHubApp().installUrl` or a github_connect_link tool.
2247
+ * Compatibility URL for previously issued state. New installation binding is
2248
+ * disabled, so the endpoint validates state and terminates with HTTP 410.
1956
2249
  */
1957
2250
  githubConnectUrl(workspaceId: string, state: string): string {
1958
2251
  return this.url(`/v1/workspaces/${workspaceId}/github/connect`, { state });
@@ -1973,6 +2266,14 @@ export class OpenGeniClient {
1973
2266
  );
1974
2267
  }
1975
2268
 
2269
+ /** Remove one workspace binding without uninstalling the GitHub App itself. */
2270
+ async unlinkGitHubInstallation(workspaceId: string, installationId: number): Promise<void> {
2271
+ await this.requestVoid(
2272
+ "DELETE",
2273
+ `/v1/workspaces/${workspaceId}/github/installations/${installationId}`,
2274
+ );
2275
+ }
2276
+
1976
2277
  /** Build a GitHub App manifest + the GitHub URL to submit it to. */
1977
2278
  async createGitHubAppManifest(
1978
2279
  workspaceId: string,
@@ -2058,6 +2359,7 @@ export class OpenGeniClient {
2058
2359
  return {
2059
2360
  ...(this.options.apiKey ? { Authorization: `Bearer ${this.options.apiKey}` } : {}),
2060
2361
  ...extra,
2362
+ [OPENGENI_API_CONTRACT_HEADER]: OPENGENI_API_CONTRACT_REVISION,
2061
2363
  };
2062
2364
  }
2063
2365
 
@@ -2198,6 +2500,7 @@ export class OpenGeniClient {
2198
2500
  path: string,
2199
2501
  body?: unknown,
2200
2502
  query: Record<string, string> = {},
2503
+ options: OpenGeniRequestOptions = {},
2201
2504
  ): Promise<T> {
2202
2505
  const response = await this.fetchImpl(this.url(path, query), {
2203
2506
  method,
@@ -2207,7 +2510,9 @@ export class OpenGeniClient {
2207
2510
  ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
2208
2511
  },
2209
2512
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2513
+ ...(options.signal ? { signal: options.signal } : {}),
2210
2514
  });
2515
+ assertApiContractResponse(response);
2211
2516
  if (!response.ok) {
2212
2517
  throw new OpenGeniApiError(response.status, await safeText(response));
2213
2518
  }
@@ -2225,12 +2530,20 @@ export class OpenGeniClient {
2225
2530
  },
2226
2531
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2227
2532
  });
2533
+ assertApiContractResponse(response);
2228
2534
  if (!response.ok) {
2229
2535
  throw new OpenGeniApiError(response.status, await safeText(response));
2230
2536
  }
2231
2537
  }
2232
2538
  }
2233
2539
 
2540
+ function assertApiContractResponse(response: Response): void {
2541
+ const actual = response.headers.get(OPENGENI_API_CONTRACT_HEADER);
2542
+ if (actual && actual !== OPENGENI_API_CONTRACT_REVISION) {
2543
+ throw new OpenGeniApiContractMismatchError(OPENGENI_API_CONTRACT_REVISION, actual);
2544
+ }
2545
+ }
2546
+
2234
2547
  async function safeText(response: Response): Promise<string> {
2235
2548
  try {
2236
2549
  return await response.text();