@aexhq/sdk 0.28.0 → 0.28.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.
@@ -9,6 +9,14 @@
9
9
  * reconnects (no gap, no duplicate). It stops on a terminal event, on abort,
10
10
  * or when the caller breaks the iterator.
11
11
  *
12
+ * A silently half-open socket (no close/error, no frames) is the dangerous
13
+ * case: the read loop would block forever and MISS a terminal that was already
14
+ * persisted server-side. So the client sends a tiny keep-alive ping the
15
+ * coordinator auto-responds to (hibernation-safe, no DO wake) and runs an idle
16
+ * watchdog: if no frame arrives within {@link CoordinatorStreamOptions.idleTimeoutMs},
17
+ * the socket is treated as dead and reconnected — resume-from-cursor then
18
+ * replays the terminal.
19
+ *
12
20
  * Filtering and projection are the client's concern (the wire carries the
13
21
  * whole run): compose {@link filterStream} with the envelope guards, and
14
22
  * {@link mapStream} with {@link toAGUI}, on top of this stream.
@@ -23,6 +31,8 @@ export interface WebSocketLike {
23
31
  addEventListener(type: "open" | "message" | "close" | "error", listener: (ev: {
24
32
  data?: unknown;
25
33
  }) => void): void;
34
+ /** Send a keep-alive ping. Optional: a transport without it just relies on real events to reset the watchdog. */
35
+ send?(data: string): void;
26
36
  }
27
37
  export type WebSocketFactory = (url: string) => WebSocketLike;
28
38
  export interface CoordinatorStreamOptions {
@@ -47,6 +57,20 @@ export interface CoordinatorStreamOptions {
47
57
  * once a subsequent `getRun` is guaranteed terminal.
48
58
  */
49
59
  readonly isTerminal?: (event: AexEvent) => boolean;
60
+ /**
61
+ * Half-open watchdog window. If no frame (a real event OR a keep-alive pong)
62
+ * arrives within this many ms, the socket is treated as dead and reconnected
63
+ * (resume from cursor). Default 45s. Set 0 to disable.
64
+ */
65
+ readonly idleTimeoutMs?: number;
66
+ /**
67
+ * Client keep-alive ping cadence. The client sends {@link COORDINATOR_PING},
68
+ * which the coordinator auto-responds to WITHOUT waking the durable object, so
69
+ * a legitimately quiet run keeps the socket measurably alive and does not trip
70
+ * the watchdog. Default 15s. Set 0 to disable (then only real events reset the
71
+ * watchdog → quiet runs may reconnect).
72
+ */
73
+ readonly pingIntervalMs?: number;
50
74
  }
51
75
  export declare function streamCoordinatorEvents(opts: CoordinatorStreamOptions): AsyncGenerator<AexEvent, void, void>;
52
76
  /** Async-iterable filter — keep only events matching the predicate. */
@@ -9,6 +9,14 @@
9
9
  * reconnects (no gap, no duplicate). It stops on a terminal event, on abort,
10
10
  * or when the caller breaks the iterator.
11
11
  *
12
+ * A silently half-open socket (no close/error, no frames) is the dangerous
13
+ * case: the read loop would block forever and MISS a terminal that was already
14
+ * persisted server-side. So the client sends a tiny keep-alive ping the
15
+ * coordinator auto-responds to (hibernation-safe, no DO wake) and runs an idle
16
+ * watchdog: if no frame arrives within {@link CoordinatorStreamOptions.idleTimeoutMs},
17
+ * the socket is treated as dead and reconnected — resume-from-cursor then
18
+ * replays the terminal.
19
+ *
12
20
  * Filtering and projection are the client's concern (the wire carries the
13
21
  * whole run): compose {@link filterStream} with the envelope guards, and
14
22
  * {@link mapStream} with {@link toAGUI}, on top of this stream.
@@ -17,11 +25,23 @@
17
25
  * (Bun and Node 22+ ship it; no dependency) and tests drive a fake.
18
26
  */
19
27
  const isTerminalType = (e) => e.type === "RUN_FINISHED" || e.type === "RUN_ERROR";
28
+ /**
29
+ * Keep-alive ping the client sends; the coordinator answers it via
30
+ * `setWebSocketAutoResponse` without waking the DO. Must stay byte-identical to
31
+ * the coordinator's pair (aex-platform `packages/shared/src/event-stream-client.ts`).
32
+ */
33
+ const COORDINATOR_PING = "aex:ping";
34
+ /** Default half-open watchdog window — 3× the ping cadence, so 2 pongs can be lost. */
35
+ const DEFAULT_IDLE_TIMEOUT_MS = 45_000;
36
+ /** Default client keep-alive ping cadence. */
37
+ const DEFAULT_PING_INTERVAL_MS = 15_000;
20
38
  export async function* streamCoordinatorEvents(opts) {
21
39
  const makeWs = opts.webSocketFactory ?? ((url) => new WebSocket(url));
22
40
  const isTerminal = opts.isTerminal ?? isTerminalType;
23
41
  const reconnectDelayMs = opts.reconnectDelayMs ?? 500;
24
42
  const maxReconnects = opts.maxReconnects ?? Number.POSITIVE_INFINITY;
43
+ const idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
44
+ const pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
25
45
  let cursor = (opts.from ?? 0) - 1;
26
46
  let attempts = 0;
27
47
  let done = false;
@@ -42,7 +62,50 @@ export async function* streamCoordinatorEvents(opts) {
42
62
  r();
43
63
  }
44
64
  };
65
+ let idleTimer = null;
66
+ let pingTimer = null;
67
+ const stopTimers = () => {
68
+ if (idleTimer !== null) {
69
+ clearTimeout(idleTimer);
70
+ idleTimer = null;
71
+ }
72
+ if (pingTimer !== null) {
73
+ clearInterval(pingTimer);
74
+ pingTimer = null;
75
+ }
76
+ };
77
+ // Re-arm on every inbound frame. On expiry the socket is presumed half-open
78
+ // → close it and let the loop fall through to reconnect (resume from cursor).
79
+ const armIdle = () => {
80
+ if (idleTimeoutMs <= 0)
81
+ return;
82
+ if (idleTimer !== null)
83
+ clearTimeout(idleTimer);
84
+ idleTimer = setTimeout(() => {
85
+ idleTimer = null;
86
+ if (closed)
87
+ return;
88
+ closed = true;
89
+ disconnectReason = "idle";
90
+ closeQuietly(ws);
91
+ wake();
92
+ }, idleTimeoutMs);
93
+ };
94
+ ws.addEventListener("open", () => {
95
+ armIdle();
96
+ if (pingIntervalMs > 0 && typeof ws.send === "function") {
97
+ pingTimer = setInterval(() => {
98
+ try {
99
+ ws.send(COORDINATOR_PING);
100
+ }
101
+ catch {
102
+ // socket not open / send unsupported — the idle watchdog still covers it
103
+ }
104
+ }, pingIntervalMs);
105
+ }
106
+ });
45
107
  ws.addEventListener("message", (ev) => {
108
+ armIdle();
46
109
  const data = typeof ev.data === "string" ? ev.data : "";
47
110
  if (!data)
48
111
  return;
@@ -58,22 +121,33 @@ export async function* streamCoordinatorEvents(opts) {
58
121
  }
59
122
  });
60
123
  ws.addEventListener("close", (ev) => {
61
- closed = true;
62
- const code = ev?.code;
63
- disconnectReason = `close${typeof code === "number" ? ` code=${code}` : ""}`;
124
+ stopTimers();
125
+ // Don't clobber a reason already decided (idle/abort closes the socket too).
126
+ if (!closed) {
127
+ closed = true;
128
+ const code = ev?.code;
129
+ disconnectReason = `close${typeof code === "number" ? ` code=${code}` : ""}`;
130
+ }
64
131
  wake();
65
132
  });
66
133
  ws.addEventListener("error", () => {
67
- closed = true;
68
- disconnectReason = "error";
134
+ stopTimers();
135
+ if (!closed) {
136
+ closed = true;
137
+ disconnectReason = "error";
138
+ }
69
139
  wake();
70
140
  });
71
141
  const onAbort = () => {
72
142
  closeQuietly(ws);
73
143
  closed = true;
144
+ stopTimers();
74
145
  wake();
75
146
  };
76
147
  opts.signal?.addEventListener("abort", onAbort, { once: true });
148
+ // Arm immediately: a connect that never reaches "open" (and fakes that
149
+ // never emit it) must still time out rather than hang forever.
150
+ armIdle();
77
151
  try {
78
152
  while (true) {
79
153
  while (queue.length > 0) {
@@ -95,6 +169,7 @@ export async function* streamCoordinatorEvents(opts) {
95
169
  }
96
170
  }
97
171
  finally {
172
+ stopTimers();
98
173
  opts.signal?.removeEventListener("abort", onAbort);
99
174
  }
100
175
  if (done || opts.signal?.aborted)
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  *
7
7
  * Used by the (future) User-Agent header on outbound SDK requests.
8
8
  */
9
- export declare const SDK_VERSION = "0.28.0";
9
+ export declare const SDK_VERSION = "0.28.1";
package/dist/version.js CHANGED
@@ -6,5 +6,5 @@
6
6
  *
7
7
  * Used by the (future) User-Agent header on outbound SDK requests.
8
8
  */
9
- export const SDK_VERSION = "0.28.0";
9
+ export const SDK_VERSION = "0.28.1";
10
10
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexhq/sdk",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "TypeScript SDK for running autonomous agent sessions across providers (Anthropic, OpenAI, DeepSeek, Gemini, Mistral) behind one interface.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {