@astralform/js 4.1.0 → 4.3.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/dist/index.d.cts CHANGED
@@ -120,6 +120,19 @@ interface AstralformBaseConfig {
120
120
  baseURL?: string;
121
121
  /** Supply a custom fetch (SSR, testing, custom interceptors). */
122
122
  fetch?: typeof globalThis.fetch;
123
+ /**
124
+ * Abort a REST request — connect, headers, AND body read — after this many
125
+ * milliseconds. Defaults to 30_000. Does not apply to `uploadFile` (large
126
+ * files on slow uplinks) or to SSE streaming, which is long-lived by design
127
+ * and carries its own `AbortSignal`.
128
+ *
129
+ * Note that unlike axios and friends, `0` does NOT mean "no timeout": any
130
+ * non-positive or non-finite value falls back to the default. REST calls
131
+ * cannot opt out of the deadline — an unbounded REST request is the bug
132
+ * this exists to prevent, and it fails silently (a stalled body strands the
133
+ * caller forever with nothing to react to).
134
+ */
135
+ timeoutMs?: number;
123
136
  }
124
137
  interface AstralformApiKeyConfig extends AstralformBaseConfig {
125
138
  /** Agent API key (`sk_live_...` or `sk_test_...`). */
@@ -773,6 +786,7 @@ interface ConversationAsset {
773
786
  declare class AstralformClient {
774
787
  private readonly baseURL;
775
788
  private readonly fetchFn;
789
+ private readonly timeoutMs;
776
790
  /**
777
791
  * Auth state is mutable so callers can rotate access tokens or switch
778
792
  * agent context without re-instantiating the client. API-key mode is
@@ -782,7 +796,7 @@ declare class AstralformClient {
782
796
  constructor(config: AstralformConfig);
783
797
  /**
784
798
  * Replace the current OIDC access token without reconstructing the client.
785
- * Use after refreshing via the host's token manager (e.g., Supabase JS SDK).
799
+ * Use after refreshing via the host app's own token manager.
786
800
  * Throws if the client was created in API-key mode.
787
801
  */
788
802
  updateAccessToken(accessToken: string): void;
@@ -819,7 +833,23 @@ declare class AstralformClient {
819
833
  */
820
834
  private get authHeaders();
821
835
  private get headers();
836
+ /**
837
+ * Run one REST exchange under a single deadline covering connect, headers,
838
+ * AND the body read. The body read is the part that matters: `json()` used
839
+ * to sit outside every guard, so a response whose headers arrived but whose
840
+ * body stalled hung forever — silently stranding callers that await it
841
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
842
+ * before it ever fetched the events it renders from).
843
+ *
844
+ * The controller is created per request and is deliberately NOT the
845
+ * session's — that one means "the user cancelled this turn" and is null
846
+ * outside a live turn. Aborting frees the socket; the race guarantees a
847
+ * rejection even when an injected `fetch` ignores the signal.
848
+ */
849
+ private withDeadline;
822
850
  private request;
851
+ /** Fetch + status handling. Always called inside `withDeadline`. */
852
+ private send;
823
853
  get<T>(path: string): Promise<T>;
824
854
  post<T>(path: string, body: unknown): Promise<T>;
825
855
  private del;
@@ -950,6 +980,13 @@ declare class ToolRegistry {
950
980
  }
951
981
 
952
982
  type ChatEventHandler = (event: ChatEvent) => void;
983
+ /**
984
+ * Conversations fetched per page, by ``connect`` and ``loadMoreConversations``
985
+ * alike. The two must use the same size: the offset is derived from how many
986
+ * rows the server has returned so far, so a first page of a different size
987
+ * would leave the second page's offset pointing at the wrong row.
988
+ */
989
+ declare const CONVERSATION_PAGE_SIZE = 50;
953
990
  /**
954
991
  * ChatSession — translates the backend wire protocol into typed ChatEvents
955
992
  * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
@@ -970,6 +1007,17 @@ declare class ChatSession {
970
1007
  readonly protocols: ProtocolRegistry<ProtocolAdapter>;
971
1008
  conversationId: string | null;
972
1009
  conversations: Conversation[];
1010
+ /**
1011
+ * Whether another page of conversations may exist on the server.
1012
+ *
1013
+ * Inferred from the last page being full, since the list endpoint returns a
1014
+ * bare array with no total. A total that happens to be an exact multiple of
1015
+ * the page size therefore costs one extra empty request before this flips —
1016
+ * cheaper than adding a count query to every list call.
1017
+ */
1018
+ hasMoreConversations: boolean;
1019
+ /** True while ``loadMoreConversations`` is in flight. */
1020
+ isLoadingConversations: boolean;
973
1021
  messages: Message[];
974
1022
  isStreaming: boolean;
975
1023
  agentStatus: AgentStatus | null;
@@ -977,6 +1025,32 @@ declare class ChatSession {
977
1025
  skills: SkillInfo[];
978
1026
  enabledClientTools: Set<string>;
979
1027
  modelDisplayName: string | null;
1028
+ /**
1029
+ * Ids of conversations the SERVER has handed us, which is the paging offset.
1030
+ *
1031
+ * Deliberately not ``conversations.length``. That array also holds
1032
+ * conversations created locally and unshifted on top (``createNewConversation``,
1033
+ * and the auto-created conversation in ``consumeJobStream``), so using its
1034
+ * length as the offset would over-count and silently skip a row of real
1035
+ * history on the next page. Tracking ids rather than a counter also makes
1036
+ * deletion self-correcting: removing a server-sourced conversation shifts
1037
+ * every later page up by one, and dropping its id from this set is exactly
1038
+ * that shift — while deleting a purely local one correctly changes nothing.
1039
+ */
1040
+ private serverConversationIds;
1041
+ /**
1042
+ * Bumped every time ``connect()`` re-seeds the conversation list.
1043
+ *
1044
+ * A ``loadMoreConversations`` request issued before a re-seed describes the
1045
+ * OLD paging state, so applying its response afterwards both appends the
1046
+ * wrong rows and corrupts the offset. Concretely: with 100 rows held, an
1047
+ * offset-100 response landing after a reconnect has reset to rows 0-49 would
1048
+ * append rows 100-149 — a 50-row hole — and leave the id set at 100, so every
1049
+ * later page re-requests offset 100 and never advances again. The generation
1050
+ * is captured before the await and rechecked after, so a superseded response
1051
+ * is discarded instead.
1052
+ */
1053
+ private conversationsGeneration;
980
1054
  private accumulatedText;
981
1055
  private currentTextPath;
982
1056
  private handlers;
@@ -1078,6 +1152,40 @@ declare class ChatSession {
1078
1152
  * job's events.
1079
1153
  */
1080
1154
  switchConversation(id: string, jobId?: string): Promise<void>;
1155
+ /**
1156
+ * Append the next page of conversation history to ``conversations``.
1157
+ *
1158
+ * The list is ordered ``updated_at DESC`` and paged by offset, so a
1159
+ * conversation bumped to the top mid-scroll can surface again in a later
1160
+ * page; ids already held are dropped rather than duplicated. Returns only
1161
+ * the conversations actually appended, which may be empty even on a full
1162
+ * page. Rejects on network failure with ``hasMoreConversations`` still true,
1163
+ * so the caller can retry.
1164
+ *
1165
+ * KNOWN LIMITATION — offset paging is only stable while the prefix already
1166
+ * consumed stays put. The offset tracking here corrects for perturbations
1167
+ * THIS session causes (local unshifts, ``deleteConversation``), but not for
1168
+ * ones it never sees:
1169
+ *
1170
+ * - a conversation this session hasn't loaded yet is bumped to the top (a
1171
+ * headless routine or another device posting to it), pushing the whole
1172
+ * list down — it lands inside the consumed prefix, which no later offset
1173
+ * revisits;
1174
+ * - a conversation is deleted from another tab/device, shrinking the list so
1175
+ * the next offset lands one row too far in.
1176
+ *
1177
+ * Each perturbation costs at most one conversation off the sidebar, and only
1178
+ * until the next ``connect()`` — that re-seeds page 1 and resets the paging
1179
+ * state, so a reload or reconnect always recovers it. Nothing is lost
1180
+ * server-side. Both cases are pinned by tests in
1181
+ * ``tests/conversation-paging.test.ts``.
1182
+ *
1183
+ * Closing the gap properly needs a stable server cursor (keyset paging on
1184
+ * ``(updated_at, id)``) rather than a raw offset, which is a backend change —
1185
+ * tracking ids client-side cannot discover a row that moved into a region
1186
+ * already scanned.
1187
+ */
1188
+ loadMoreConversations(): Promise<Conversation[]>;
1081
1189
  deleteConversation(id: string): Promise<void>;
1082
1190
  toggleClientTool(name: string): boolean;
1083
1191
  }
@@ -1310,4 +1418,4 @@ declare function isEmbeddedResource(value: unknown): value is {
1310
1418
  */
1311
1419
  declare function parseEmbeddedResource(value: unknown): EmbeddedResource | null;
1312
1420
 
1313
- export { type ActiveJob, type AgentIdentity, type AgentInfo, type AgentStatus, type AssetCreatedPayload, type AstralformApiKeyConfig, AstralformClient, type AstralformConfig, AstralformError, type AstralformUserTokenConfig, type AttachmentStagedPayload, AuthenticationError, type BlockDeltaPayload, type ChatEvent, ChatEventType, type ChatEventTypeValue, ChatSession, type ChatStorage, type ChatStreamEvent, type ChatStreamRequest, ConnectionError, type ContextUpdatePayload, type ContextWarningPayload, type Conversation, type ConversationAsset, type ConversationEvent, type DesktopStreamPayload, type EmbeddedResource, type FeedbackRequest, type FeedbackResponse, InMemoryStorage, type JobCreateResponse, type JobStatus, type JobSummary, LLMNotConfiguredError, type MemoryRecallPayload, type MemoryRecord, type MemoryUpdatePayload, type Message, type ModelChoiceOptions, type ModelOption, type MyToolGrantsPage, type PromptSuggestionPayload, type ProtocolAdapter, ProtocolRegistry, RateLimitError, type RateLimitErrorDetails, type RawSseEvent, type ReasoningEffort, type SendOptions, ServerError, type SkillInfo, StreamAbortedError, type StreamJobSSEOptions, StreamManager, type StreamManagerEvent, type StreamState, type SubagentStartPayload, type SubagentStopPayload, type TaskStatus, type TeamAgentSummary, type TeamSummary, type TitleGeneratedPayload, type TodoItem, type TodoUpdatePayload, type ToolApprovalDecision, type ToolApprovalGrantedPayload, type ToolApprovalRequest, type ToolApprovalRequestedPayload, type ToolApprovalScope, type ToolCallRequest, type ToolDefinition, type ToolGrant, type ToolHandler, type ToolHarnessWarningPayload, type ToolPermissionDeniedPayload, ToolRegistry, type ToolResult, type ToolResultRequest, type TurnUsage, type UIComponentsConfig, type UserUnavailablePayload, type WireBlockDelta, type WireBlockDeltaPayload, type WireBlockKind, type WireBlockStart, type WireBlockStatus, type WireBlockStop, type WireCustomEvent, type WireErrorEvent, type WireEvent, type WireInputArgDelta, type WireInputDelta, type WireKeepalive, type WireMessageStart, type WireMessageStop, type WireOutputDelta, type WireRetryEvent, type WireSignatureDelta, type WireStallWarning, type WireStatusDelta, type WireStopReason, type WireTextDelta, type WireThinkingDelta, type WorkspaceReadyPayload, generateId, isEmbeddedResource, mapSseToChat, parseEmbeddedResource, replayEvents, streamJobSSE, translateDelta };
1421
+ export { type ActiveJob, type AgentIdentity, type AgentInfo, type AgentStatus, type AssetCreatedPayload, type AstralformApiKeyConfig, AstralformClient, type AstralformConfig, AstralformError, type AstralformUserTokenConfig, type AttachmentStagedPayload, AuthenticationError, type BlockDeltaPayload, CONVERSATION_PAGE_SIZE, type ChatEvent, ChatEventType, type ChatEventTypeValue, ChatSession, type ChatStorage, type ChatStreamEvent, type ChatStreamRequest, ConnectionError, type ContextUpdatePayload, type ContextWarningPayload, type Conversation, type ConversationAsset, type ConversationEvent, type DesktopStreamPayload, type EmbeddedResource, type FeedbackRequest, type FeedbackResponse, InMemoryStorage, type JobCreateResponse, type JobStatus, type JobSummary, LLMNotConfiguredError, type MemoryRecallPayload, type MemoryRecord, type MemoryUpdatePayload, type Message, type ModelChoiceOptions, type ModelOption, type MyToolGrantsPage, type PromptSuggestionPayload, type ProtocolAdapter, ProtocolRegistry, RateLimitError, type RateLimitErrorDetails, type RawSseEvent, type ReasoningEffort, type SendOptions, ServerError, type SkillInfo, StreamAbortedError, type StreamJobSSEOptions, StreamManager, type StreamManagerEvent, type StreamState, type SubagentStartPayload, type SubagentStopPayload, type TaskStatus, type TeamAgentSummary, type TeamSummary, type TitleGeneratedPayload, type TodoItem, type TodoUpdatePayload, type ToolApprovalDecision, type ToolApprovalGrantedPayload, type ToolApprovalRequest, type ToolApprovalRequestedPayload, type ToolApprovalScope, type ToolCallRequest, type ToolDefinition, type ToolGrant, type ToolHandler, type ToolHarnessWarningPayload, type ToolPermissionDeniedPayload, ToolRegistry, type ToolResult, type ToolResultRequest, type TurnUsage, type UIComponentsConfig, type UserUnavailablePayload, type WireBlockDelta, type WireBlockDeltaPayload, type WireBlockKind, type WireBlockStart, type WireBlockStatus, type WireBlockStop, type WireCustomEvent, type WireErrorEvent, type WireEvent, type WireInputArgDelta, type WireInputDelta, type WireKeepalive, type WireMessageStart, type WireMessageStop, type WireOutputDelta, type WireRetryEvent, type WireSignatureDelta, type WireStallWarning, type WireStatusDelta, type WireStopReason, type WireTextDelta, type WireThinkingDelta, type WorkspaceReadyPayload, generateId, isEmbeddedResource, mapSseToChat, parseEmbeddedResource, replayEvents, streamJobSSE, translateDelta };
package/dist/index.d.ts CHANGED
@@ -120,6 +120,19 @@ interface AstralformBaseConfig {
120
120
  baseURL?: string;
121
121
  /** Supply a custom fetch (SSR, testing, custom interceptors). */
122
122
  fetch?: typeof globalThis.fetch;
123
+ /**
124
+ * Abort a REST request — connect, headers, AND body read — after this many
125
+ * milliseconds. Defaults to 30_000. Does not apply to `uploadFile` (large
126
+ * files on slow uplinks) or to SSE streaming, which is long-lived by design
127
+ * and carries its own `AbortSignal`.
128
+ *
129
+ * Note that unlike axios and friends, `0` does NOT mean "no timeout": any
130
+ * non-positive or non-finite value falls back to the default. REST calls
131
+ * cannot opt out of the deadline — an unbounded REST request is the bug
132
+ * this exists to prevent, and it fails silently (a stalled body strands the
133
+ * caller forever with nothing to react to).
134
+ */
135
+ timeoutMs?: number;
123
136
  }
124
137
  interface AstralformApiKeyConfig extends AstralformBaseConfig {
125
138
  /** Agent API key (`sk_live_...` or `sk_test_...`). */
@@ -773,6 +786,7 @@ interface ConversationAsset {
773
786
  declare class AstralformClient {
774
787
  private readonly baseURL;
775
788
  private readonly fetchFn;
789
+ private readonly timeoutMs;
776
790
  /**
777
791
  * Auth state is mutable so callers can rotate access tokens or switch
778
792
  * agent context without re-instantiating the client. API-key mode is
@@ -782,7 +796,7 @@ declare class AstralformClient {
782
796
  constructor(config: AstralformConfig);
783
797
  /**
784
798
  * Replace the current OIDC access token without reconstructing the client.
785
- * Use after refreshing via the host's token manager (e.g., Supabase JS SDK).
799
+ * Use after refreshing via the host app's own token manager.
786
800
  * Throws if the client was created in API-key mode.
787
801
  */
788
802
  updateAccessToken(accessToken: string): void;
@@ -819,7 +833,23 @@ declare class AstralformClient {
819
833
  */
820
834
  private get authHeaders();
821
835
  private get headers();
836
+ /**
837
+ * Run one REST exchange under a single deadline covering connect, headers,
838
+ * AND the body read. The body read is the part that matters: `json()` used
839
+ * to sit outside every guard, so a response whose headers arrived but whose
840
+ * body stalled hung forever — silently stranding callers that await it
841
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
842
+ * before it ever fetched the events it renders from).
843
+ *
844
+ * The controller is created per request and is deliberately NOT the
845
+ * session's — that one means "the user cancelled this turn" and is null
846
+ * outside a live turn. Aborting frees the socket; the race guarantees a
847
+ * rejection even when an injected `fetch` ignores the signal.
848
+ */
849
+ private withDeadline;
822
850
  private request;
851
+ /** Fetch + status handling. Always called inside `withDeadline`. */
852
+ private send;
823
853
  get<T>(path: string): Promise<T>;
824
854
  post<T>(path: string, body: unknown): Promise<T>;
825
855
  private del;
@@ -950,6 +980,13 @@ declare class ToolRegistry {
950
980
  }
951
981
 
952
982
  type ChatEventHandler = (event: ChatEvent) => void;
983
+ /**
984
+ * Conversations fetched per page, by ``connect`` and ``loadMoreConversations``
985
+ * alike. The two must use the same size: the offset is derived from how many
986
+ * rows the server has returned so far, so a first page of a different size
987
+ * would leave the second page's offset pointing at the wrong row.
988
+ */
989
+ declare const CONVERSATION_PAGE_SIZE = 50;
953
990
  /**
954
991
  * ChatSession — translates the backend wire protocol into typed ChatEvents
955
992
  * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
@@ -970,6 +1007,17 @@ declare class ChatSession {
970
1007
  readonly protocols: ProtocolRegistry<ProtocolAdapter>;
971
1008
  conversationId: string | null;
972
1009
  conversations: Conversation[];
1010
+ /**
1011
+ * Whether another page of conversations may exist on the server.
1012
+ *
1013
+ * Inferred from the last page being full, since the list endpoint returns a
1014
+ * bare array with no total. A total that happens to be an exact multiple of
1015
+ * the page size therefore costs one extra empty request before this flips —
1016
+ * cheaper than adding a count query to every list call.
1017
+ */
1018
+ hasMoreConversations: boolean;
1019
+ /** True while ``loadMoreConversations`` is in flight. */
1020
+ isLoadingConversations: boolean;
973
1021
  messages: Message[];
974
1022
  isStreaming: boolean;
975
1023
  agentStatus: AgentStatus | null;
@@ -977,6 +1025,32 @@ declare class ChatSession {
977
1025
  skills: SkillInfo[];
978
1026
  enabledClientTools: Set<string>;
979
1027
  modelDisplayName: string | null;
1028
+ /**
1029
+ * Ids of conversations the SERVER has handed us, which is the paging offset.
1030
+ *
1031
+ * Deliberately not ``conversations.length``. That array also holds
1032
+ * conversations created locally and unshifted on top (``createNewConversation``,
1033
+ * and the auto-created conversation in ``consumeJobStream``), so using its
1034
+ * length as the offset would over-count and silently skip a row of real
1035
+ * history on the next page. Tracking ids rather than a counter also makes
1036
+ * deletion self-correcting: removing a server-sourced conversation shifts
1037
+ * every later page up by one, and dropping its id from this set is exactly
1038
+ * that shift — while deleting a purely local one correctly changes nothing.
1039
+ */
1040
+ private serverConversationIds;
1041
+ /**
1042
+ * Bumped every time ``connect()`` re-seeds the conversation list.
1043
+ *
1044
+ * A ``loadMoreConversations`` request issued before a re-seed describes the
1045
+ * OLD paging state, so applying its response afterwards both appends the
1046
+ * wrong rows and corrupts the offset. Concretely: with 100 rows held, an
1047
+ * offset-100 response landing after a reconnect has reset to rows 0-49 would
1048
+ * append rows 100-149 — a 50-row hole — and leave the id set at 100, so every
1049
+ * later page re-requests offset 100 and never advances again. The generation
1050
+ * is captured before the await and rechecked after, so a superseded response
1051
+ * is discarded instead.
1052
+ */
1053
+ private conversationsGeneration;
980
1054
  private accumulatedText;
981
1055
  private currentTextPath;
982
1056
  private handlers;
@@ -1078,6 +1152,40 @@ declare class ChatSession {
1078
1152
  * job's events.
1079
1153
  */
1080
1154
  switchConversation(id: string, jobId?: string): Promise<void>;
1155
+ /**
1156
+ * Append the next page of conversation history to ``conversations``.
1157
+ *
1158
+ * The list is ordered ``updated_at DESC`` and paged by offset, so a
1159
+ * conversation bumped to the top mid-scroll can surface again in a later
1160
+ * page; ids already held are dropped rather than duplicated. Returns only
1161
+ * the conversations actually appended, which may be empty even on a full
1162
+ * page. Rejects on network failure with ``hasMoreConversations`` still true,
1163
+ * so the caller can retry.
1164
+ *
1165
+ * KNOWN LIMITATION — offset paging is only stable while the prefix already
1166
+ * consumed stays put. The offset tracking here corrects for perturbations
1167
+ * THIS session causes (local unshifts, ``deleteConversation``), but not for
1168
+ * ones it never sees:
1169
+ *
1170
+ * - a conversation this session hasn't loaded yet is bumped to the top (a
1171
+ * headless routine or another device posting to it), pushing the whole
1172
+ * list down — it lands inside the consumed prefix, which no later offset
1173
+ * revisits;
1174
+ * - a conversation is deleted from another tab/device, shrinking the list so
1175
+ * the next offset lands one row too far in.
1176
+ *
1177
+ * Each perturbation costs at most one conversation off the sidebar, and only
1178
+ * until the next ``connect()`` — that re-seeds page 1 and resets the paging
1179
+ * state, so a reload or reconnect always recovers it. Nothing is lost
1180
+ * server-side. Both cases are pinned by tests in
1181
+ * ``tests/conversation-paging.test.ts``.
1182
+ *
1183
+ * Closing the gap properly needs a stable server cursor (keyset paging on
1184
+ * ``(updated_at, id)``) rather than a raw offset, which is a backend change —
1185
+ * tracking ids client-side cannot discover a row that moved into a region
1186
+ * already scanned.
1187
+ */
1188
+ loadMoreConversations(): Promise<Conversation[]>;
1081
1189
  deleteConversation(id: string): Promise<void>;
1082
1190
  toggleClientTool(name: string): boolean;
1083
1191
  }
@@ -1310,4 +1418,4 @@ declare function isEmbeddedResource(value: unknown): value is {
1310
1418
  */
1311
1419
  declare function parseEmbeddedResource(value: unknown): EmbeddedResource | null;
1312
1420
 
1313
- export { type ActiveJob, type AgentIdentity, type AgentInfo, type AgentStatus, type AssetCreatedPayload, type AstralformApiKeyConfig, AstralformClient, type AstralformConfig, AstralformError, type AstralformUserTokenConfig, type AttachmentStagedPayload, AuthenticationError, type BlockDeltaPayload, type ChatEvent, ChatEventType, type ChatEventTypeValue, ChatSession, type ChatStorage, type ChatStreamEvent, type ChatStreamRequest, ConnectionError, type ContextUpdatePayload, type ContextWarningPayload, type Conversation, type ConversationAsset, type ConversationEvent, type DesktopStreamPayload, type EmbeddedResource, type FeedbackRequest, type FeedbackResponse, InMemoryStorage, type JobCreateResponse, type JobStatus, type JobSummary, LLMNotConfiguredError, type MemoryRecallPayload, type MemoryRecord, type MemoryUpdatePayload, type Message, type ModelChoiceOptions, type ModelOption, type MyToolGrantsPage, type PromptSuggestionPayload, type ProtocolAdapter, ProtocolRegistry, RateLimitError, type RateLimitErrorDetails, type RawSseEvent, type ReasoningEffort, type SendOptions, ServerError, type SkillInfo, StreamAbortedError, type StreamJobSSEOptions, StreamManager, type StreamManagerEvent, type StreamState, type SubagentStartPayload, type SubagentStopPayload, type TaskStatus, type TeamAgentSummary, type TeamSummary, type TitleGeneratedPayload, type TodoItem, type TodoUpdatePayload, type ToolApprovalDecision, type ToolApprovalGrantedPayload, type ToolApprovalRequest, type ToolApprovalRequestedPayload, type ToolApprovalScope, type ToolCallRequest, type ToolDefinition, type ToolGrant, type ToolHandler, type ToolHarnessWarningPayload, type ToolPermissionDeniedPayload, ToolRegistry, type ToolResult, type ToolResultRequest, type TurnUsage, type UIComponentsConfig, type UserUnavailablePayload, type WireBlockDelta, type WireBlockDeltaPayload, type WireBlockKind, type WireBlockStart, type WireBlockStatus, type WireBlockStop, type WireCustomEvent, type WireErrorEvent, type WireEvent, type WireInputArgDelta, type WireInputDelta, type WireKeepalive, type WireMessageStart, type WireMessageStop, type WireOutputDelta, type WireRetryEvent, type WireSignatureDelta, type WireStallWarning, type WireStatusDelta, type WireStopReason, type WireTextDelta, type WireThinkingDelta, type WorkspaceReadyPayload, generateId, isEmbeddedResource, mapSseToChat, parseEmbeddedResource, replayEvents, streamJobSSE, translateDelta };
1421
+ export { type ActiveJob, type AgentIdentity, type AgentInfo, type AgentStatus, type AssetCreatedPayload, type AstralformApiKeyConfig, AstralformClient, type AstralformConfig, AstralformError, type AstralformUserTokenConfig, type AttachmentStagedPayload, AuthenticationError, type BlockDeltaPayload, CONVERSATION_PAGE_SIZE, type ChatEvent, ChatEventType, type ChatEventTypeValue, ChatSession, type ChatStorage, type ChatStreamEvent, type ChatStreamRequest, ConnectionError, type ContextUpdatePayload, type ContextWarningPayload, type Conversation, type ConversationAsset, type ConversationEvent, type DesktopStreamPayload, type EmbeddedResource, type FeedbackRequest, type FeedbackResponse, InMemoryStorage, type JobCreateResponse, type JobStatus, type JobSummary, LLMNotConfiguredError, type MemoryRecallPayload, type MemoryRecord, type MemoryUpdatePayload, type Message, type ModelChoiceOptions, type ModelOption, type MyToolGrantsPage, type PromptSuggestionPayload, type ProtocolAdapter, ProtocolRegistry, RateLimitError, type RateLimitErrorDetails, type RawSseEvent, type ReasoningEffort, type SendOptions, ServerError, type SkillInfo, StreamAbortedError, type StreamJobSSEOptions, StreamManager, type StreamManagerEvent, type StreamState, type SubagentStartPayload, type SubagentStopPayload, type TaskStatus, type TeamAgentSummary, type TeamSummary, type TitleGeneratedPayload, type TodoItem, type TodoUpdatePayload, type ToolApprovalDecision, type ToolApprovalGrantedPayload, type ToolApprovalRequest, type ToolApprovalRequestedPayload, type ToolApprovalScope, type ToolCallRequest, type ToolDefinition, type ToolGrant, type ToolHandler, type ToolHarnessWarningPayload, type ToolPermissionDeniedPayload, ToolRegistry, type ToolResult, type ToolResultRequest, type TurnUsage, type UIComponentsConfig, type UserUnavailablePayload, type WireBlockDelta, type WireBlockDeltaPayload, type WireBlockKind, type WireBlockStart, type WireBlockStatus, type WireBlockStop, type WireCustomEvent, type WireErrorEvent, type WireEvent, type WireInputArgDelta, type WireInputDelta, type WireKeepalive, type WireMessageStart, type WireMessageStop, type WireOutputDelta, type WireRetryEvent, type WireSignatureDelta, type WireStallWarning, type WireStatusDelta, type WireStopReason, type WireTextDelta, type WireThinkingDelta, type WorkspaceReadyPayload, generateId, isEmbeddedResource, mapSseToChat, parseEmbeddedResource, replayEvents, streamJobSSE, translateDelta };
package/dist/index.js CHANGED
@@ -252,6 +252,7 @@ async function* streamJobSSE(options) {
252
252
 
253
253
  // src/client.ts
254
254
  var DEFAULT_BASE_URL = "https://api.astralform.ai";
255
+ var DEFAULT_TIMEOUT_MS = 3e4;
255
256
  function validateBaseURL(url) {
256
257
  const cleaned = url.replace(/\/+$/, "");
257
258
  try {
@@ -302,10 +303,11 @@ var AstralformClient = class {
302
303
  }
303
304
  this.baseURL = validateBaseURL(config.baseURL ?? DEFAULT_BASE_URL);
304
305
  this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
306
+ this.timeoutMs = typeof config.timeoutMs === "number" && Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : DEFAULT_TIMEOUT_MS;
305
307
  }
306
308
  /**
307
309
  * Replace the current OIDC access token without reconstructing the client.
308
- * Use after refreshing via the host's token manager (e.g., Supabase JS SDK).
310
+ * Use after refreshing via the host app's own token manager.
309
311
  * Throws if the client was created in API-key mode.
310
312
  */
311
313
  updateAccessToken(accessToken) {
@@ -392,11 +394,48 @@ var AstralformClient = class {
392
394
  "Content-Type": "application/json"
393
395
  };
394
396
  }
397
+ /**
398
+ * Run one REST exchange under a single deadline covering connect, headers,
399
+ * AND the body read. The body read is the part that matters: `json()` used
400
+ * to sit outside every guard, so a response whose headers arrived but whose
401
+ * body stalled hung forever — silently stranding callers that await it
402
+ * (a stalled `getMessages` used to leave `StreamManager.restore()` parked
403
+ * before it ever fetched the events it renders from).
404
+ *
405
+ * The controller is created per request and is deliberately NOT the
406
+ * session's — that one means "the user cancelled this turn" and is null
407
+ * outside a live turn. Aborting frees the socket; the race guarantees a
408
+ * rejection even when an injected `fetch` ignores the signal.
409
+ */
410
+ async withDeadline(run) {
411
+ const controller = new AbortController();
412
+ const timedOut = () => new ConnectionError(`Request timed out after ${this.timeoutMs}ms`);
413
+ let timer;
414
+ const deadline = new Promise((_resolve, reject) => {
415
+ timer = setTimeout(() => {
416
+ controller.abort();
417
+ reject(timedOut());
418
+ }, this.timeoutMs);
419
+ });
420
+ try {
421
+ return await Promise.race([run(controller.signal), deadline]);
422
+ } catch (err) {
423
+ if (controller.signal.aborted) throw timedOut();
424
+ throw err;
425
+ } finally {
426
+ clearTimeout(timer);
427
+ }
428
+ }
395
429
  async request(method, path, body) {
430
+ return this.withDeadline((signal) => this.send(method, path, body, signal));
431
+ }
432
+ /** Fetch + status handling. Always called inside `withDeadline`. */
433
+ async send(method, path, body, signal) {
396
434
  const response = await this.fetchFn(`${this.baseURL}${path}`, {
397
435
  method,
398
436
  headers: this.headers,
399
- body: body !== void 0 ? JSON.stringify(body) : void 0
437
+ body: body !== void 0 ? JSON.stringify(body) : void 0,
438
+ signal
400
439
  }).catch((err) => {
401
440
  throw new ConnectionError(
402
441
  err instanceof Error ? err.message : "Failed to connect"
@@ -405,13 +444,21 @@ var AstralformClient = class {
405
444
  await this.handleError(response);
406
445
  return response;
407
446
  }
447
+ // DO NOT refactor these back into `request()` + `.json()`. Parsing the body
448
+ // INSIDE the raced callback is the entire fix: `json()` outside the deadline
449
+ // is the original bug (headers arrive, body stalls, caller hangs forever).
450
+ // `request()` survives for `del()`, which never reads the body.
408
451
  async get(path) {
409
- const response = await this.request("GET", path);
410
- return response.json();
452
+ return this.withDeadline(async (signal) => {
453
+ const response = await this.send("GET", path, void 0, signal);
454
+ return await response.json();
455
+ });
411
456
  }
412
457
  async post(path, body) {
413
- const response = await this.request("POST", path, body);
414
- return response.json();
458
+ return this.withDeadline(async (signal) => {
459
+ const response = await this.send("POST", path, body, signal);
460
+ return await response.json();
461
+ });
415
462
  }
416
463
  async del(path) {
417
464
  await this.request("DELETE", path);
@@ -1185,6 +1232,7 @@ function translateWireEvent(wire) {
1185
1232
  // src/session.ts
1186
1233
  var SSE_MAX_RECONNECTS = 6;
1187
1234
  var TOOL_RESULT_MAX_RETRIES = 3;
1235
+ var CONVERSATION_PAGE_SIZE = 50;
1188
1236
  function sseReconnectDelayMs(attempt) {
1189
1237
  return Math.min(500 * 2 ** (attempt - 1), 5e3);
1190
1238
  }
@@ -1208,6 +1256,17 @@ var ChatSession = class {
1208
1256
  // State
1209
1257
  this.conversationId = null;
1210
1258
  this.conversations = [];
1259
+ /**
1260
+ * Whether another page of conversations may exist on the server.
1261
+ *
1262
+ * Inferred from the last page being full, since the list endpoint returns a
1263
+ * bare array with no total. A total that happens to be an exact multiple of
1264
+ * the page size therefore costs one extra empty request before this flips —
1265
+ * cheaper than adding a count query to every list call.
1266
+ */
1267
+ this.hasMoreConversations = false;
1268
+ /** True while ``loadMoreConversations`` is in flight. */
1269
+ this.isLoadingConversations = false;
1211
1270
  this.messages = [];
1212
1271
  this.isStreaming = false;
1213
1272
  this.agentStatus = null;
@@ -1215,6 +1274,32 @@ var ChatSession = class {
1215
1274
  this.skills = [];
1216
1275
  this.enabledClientTools = /* @__PURE__ */ new Set();
1217
1276
  this.modelDisplayName = null;
1277
+ /**
1278
+ * Ids of conversations the SERVER has handed us, which is the paging offset.
1279
+ *
1280
+ * Deliberately not ``conversations.length``. That array also holds
1281
+ * conversations created locally and unshifted on top (``createNewConversation``,
1282
+ * and the auto-created conversation in ``consumeJobStream``), so using its
1283
+ * length as the offset would over-count and silently skip a row of real
1284
+ * history on the next page. Tracking ids rather than a counter also makes
1285
+ * deletion self-correcting: removing a server-sourced conversation shifts
1286
+ * every later page up by one, and dropping its id from this set is exactly
1287
+ * that shift — while deleting a purely local one correctly changes nothing.
1288
+ */
1289
+ this.serverConversationIds = /* @__PURE__ */ new Set();
1290
+ /**
1291
+ * Bumped every time ``connect()`` re-seeds the conversation list.
1292
+ *
1293
+ * A ``loadMoreConversations`` request issued before a re-seed describes the
1294
+ * OLD paging state, so applying its response afterwards both appends the
1295
+ * wrong rows and corrupts the offset. Concretely: with 100 rows held, an
1296
+ * offset-100 response landing after a reconnect has reset to rows 0-49 would
1297
+ * append rows 100-149 — a 50-row hole — and leave the id set at 100, so every
1298
+ * later page re-requests offset 100 and never advances again. The generation
1299
+ * is captured before the await and rechecked after, so a superseded response
1300
+ * is discarded instead.
1301
+ */
1302
+ this.conversationsGeneration = 0;
1218
1303
  // Minimal in-session accumulation for the assistant message record.
1219
1304
  // Only top-level ``text`` blocks contribute; subagent / tool output
1220
1305
  // is tracked by the consumer's own block store.
@@ -1254,7 +1339,7 @@ var ChatSession = class {
1254
1339
  async connect() {
1255
1340
  const [status, conversations, agents, skills] = await Promise.allSettled([
1256
1341
  this.client.getAgentStatus(),
1257
- this.client.getConversations(),
1342
+ this.client.getConversations(CONVERSATION_PAGE_SIZE),
1258
1343
  this.client.getAgents().catch(() => []),
1259
1344
  this.client.getSkills().catch(() => [])
1260
1345
  ]);
@@ -1262,7 +1347,12 @@ var ChatSession = class {
1262
1347
  this.agentStatus = status.value;
1263
1348
  }
1264
1349
  if (conversations.status === "fulfilled") {
1350
+ this.conversationsGeneration++;
1265
1351
  this.conversations = conversations.value;
1352
+ this.serverConversationIds = new Set(
1353
+ conversations.value.map((c) => c.id)
1354
+ );
1355
+ this.hasMoreConversations = conversations.value.length === CONVERSATION_PAGE_SIZE;
1266
1356
  }
1267
1357
  if (agents.status === "fulfilled") {
1268
1358
  this.agents = agents.value;
@@ -1721,12 +1811,69 @@ var ChatSession = class {
1721
1811
  eventsResult.status === "fulfilled" ? eventsResult.value : []
1722
1812
  );
1723
1813
  }
1814
+ /**
1815
+ * Append the next page of conversation history to ``conversations``.
1816
+ *
1817
+ * The list is ordered ``updated_at DESC`` and paged by offset, so a
1818
+ * conversation bumped to the top mid-scroll can surface again in a later
1819
+ * page; ids already held are dropped rather than duplicated. Returns only
1820
+ * the conversations actually appended, which may be empty even on a full
1821
+ * page. Rejects on network failure with ``hasMoreConversations`` still true,
1822
+ * so the caller can retry.
1823
+ *
1824
+ * KNOWN LIMITATION — offset paging is only stable while the prefix already
1825
+ * consumed stays put. The offset tracking here corrects for perturbations
1826
+ * THIS session causes (local unshifts, ``deleteConversation``), but not for
1827
+ * ones it never sees:
1828
+ *
1829
+ * - a conversation this session hasn't loaded yet is bumped to the top (a
1830
+ * headless routine or another device posting to it), pushing the whole
1831
+ * list down — it lands inside the consumed prefix, which no later offset
1832
+ * revisits;
1833
+ * - a conversation is deleted from another tab/device, shrinking the list so
1834
+ * the next offset lands one row too far in.
1835
+ *
1836
+ * Each perturbation costs at most one conversation off the sidebar, and only
1837
+ * until the next ``connect()`` — that re-seeds page 1 and resets the paging
1838
+ * state, so a reload or reconnect always recovers it. Nothing is lost
1839
+ * server-side. Both cases are pinned by tests in
1840
+ * ``tests/conversation-paging.test.ts``.
1841
+ *
1842
+ * Closing the gap properly needs a stable server cursor (keyset paging on
1843
+ * ``(updated_at, id)``) rather than a raw offset, which is a backend change —
1844
+ * tracking ids client-side cannot discover a row that moved into a region
1845
+ * already scanned.
1846
+ */
1847
+ async loadMoreConversations() {
1848
+ if (this.isLoadingConversations || !this.hasMoreConversations) return [];
1849
+ this.isLoadingConversations = true;
1850
+ const generation = this.conversationsGeneration;
1851
+ try {
1852
+ const page = await this.client.getConversations(
1853
+ CONVERSATION_PAGE_SIZE,
1854
+ this.serverConversationIds.size
1855
+ );
1856
+ if (generation !== this.conversationsGeneration) return [];
1857
+ this.hasMoreConversations = page.length === CONVERSATION_PAGE_SIZE;
1858
+ const fresh = page.filter((c) => !this.serverConversationIds.has(c.id));
1859
+ for (const c of page) this.serverConversationIds.add(c.id);
1860
+ const known = new Set(this.conversations.map((c) => c.id));
1861
+ const appended = fresh.filter((c) => !known.has(c.id));
1862
+ this.conversations.push(...appended);
1863
+ return appended;
1864
+ } finally {
1865
+ this.isLoadingConversations = false;
1866
+ }
1867
+ }
1724
1868
  async deleteConversation(id) {
1725
1869
  try {
1726
1870
  await this.client.deleteConversation(id);
1727
1871
  } catch {
1728
1872
  }
1729
1873
  await this.storage.deleteConversation(id);
1874
+ if (this.serverConversationIds.delete(id)) {
1875
+ this.conversationsGeneration++;
1876
+ }
1730
1877
  this.conversations = this.conversations.filter((c) => c.id !== id);
1731
1878
  if (this.conversationId === id) {
1732
1879
  this.conversationId = null;
@@ -2111,6 +2258,7 @@ export {
2111
2258
  AstralformClient,
2112
2259
  AstralformError,
2113
2260
  AuthenticationError,
2261
+ CONVERSATION_PAGE_SIZE,
2114
2262
  ChatEventType,
2115
2263
  ChatSession,
2116
2264
  ConnectionError,