@flowy-agent-store/client 0.1.0-beta.2 → 0.1.0-beta.3

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.cjs CHANGED
@@ -26,49 +26,74 @@ function isLoopbackUrl(raw) {
26
26
  return false;
27
27
  }
28
28
  }
29
+ /** How long an unopened `connect()` waits before it fails. */
30
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
29
31
  var WebSocketTransport = class {
30
32
  socket = null;
31
33
  nextId = 1;
32
34
  pending = /* @__PURE__ */ new Map();
33
35
  listeners = /* @__PURE__ */ new Set();
34
- connectResolvers = [];
35
- connectRejecters = [];
36
+ lifecycleListeners = /* @__PURE__ */ new Set();
37
+ lifecycle = null;
36
38
  closedByCaller = false;
39
+ /** Single in-flight connect shared by concurrent callers; null once settled. */
40
+ connecting = null;
37
41
  requestTimeoutMs;
42
+ connectTimeoutMs;
38
43
  url;
39
44
  token;
40
45
  constructor(url, options = {}) {
41
46
  this.url = url;
42
47
  this.token = options.token;
43
48
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
49
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
44
50
  }
45
51
  get connected() {
46
52
  return this.socket?.readyState === WebSocket.OPEN;
47
53
  }
48
54
  connect() {
49
55
  if (this.connected) return Promise.resolve();
56
+ if (this.connecting) return this.connecting.promise;
50
57
  this.closedByCaller = false;
51
58
  const target = this.token ? `${this.url}${this.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.token)}` : this.url;
52
59
  const socket = new WebSocket(target);
53
60
  this.socket = socket;
61
+ let resolveConnect;
62
+ let rejectConnect;
63
+ const promise = new Promise((resolve, reject) => {
64
+ resolveConnect = resolve;
65
+ rejectConnect = reject;
66
+ });
67
+ const timer = setTimeout(() => {
68
+ this.detachSocket(socket);
69
+ this.rejectConnect(new _flowy_agent_store_protocol.TransportError("connect", `app-server connect timed out after ${this.connectTimeoutMs}ms`, { retryable: true }));
70
+ }, this.connectTimeoutMs);
71
+ this.connecting = {
72
+ promise,
73
+ resolve: resolveConnect,
74
+ reject: rejectConnect,
75
+ timer
76
+ };
54
77
  socket.onopen = () => {
55
- for (const resolve of this.connectResolvers.splice(0)) resolve();
78
+ if (this.socket !== socket) return;
79
+ this.resolveConnect();
80
+ this.setLifecycle("open");
56
81
  };
57
82
  socket.onmessage = (event) => {
83
+ if (this.socket !== socket) return;
58
84
  this.handleMessage(event.data);
59
85
  };
60
86
  socket.onclose = (event) => {
87
+ if (this.socket !== socket) return;
61
88
  this.failAllPending(new _flowy_agent_store_protocol.TransportError("close", `app-server connection closed (code ${event.code})`, { retryable: !this.closedByCaller }));
62
89
  this.socket = null;
63
- for (const reject of this.connectRejecters.splice(0)) reject(new _flowy_agent_store_protocol.TransportError("connect", "app-server connection closed before opening"));
90
+ this.setLifecycle("closed");
91
+ this.rejectConnect(new _flowy_agent_store_protocol.TransportError("connect", "app-server connection closed before opening"));
64
92
  };
65
93
  socket.onerror = () => {
66
- this.socket?.close();
94
+ if (this.socket === socket) socket.close();
67
95
  };
68
- return new Promise((resolve, reject) => {
69
- this.connectResolvers.push(resolve);
70
- this.connectRejecters.push(reject);
71
- });
96
+ return promise;
72
97
  }
73
98
  request(method, params) {
74
99
  const socket = this.requireSocket();
@@ -118,15 +143,62 @@ var WebSocketTransport = class {
118
143
  this.listeners.delete(listener);
119
144
  };
120
145
  }
146
+ /**
147
+ * Observe the channel lifecycle. `closed` is only reported when an
148
+ * established connection is lost (a failed first dial is not a disconnect),
149
+ * and `open` follows it on the next successful dial — that pair is what a
150
+ * host should treat as a reconnect. Unlike `onNotification`, these listeners
151
+ * survive `close()`, since they exist to drive the reconnection itself.
152
+ */
153
+ onLifecycle(listener) {
154
+ this.lifecycleListeners.add(listener);
155
+ return () => {
156
+ this.lifecycleListeners.delete(listener);
157
+ };
158
+ }
121
159
  close() {
122
160
  this.closedByCaller = true;
123
- this.socket?.close();
124
- this.socket = null;
161
+ this.detachSocket(this.socket);
162
+ this.rejectConnect(new _flowy_agent_store_protocol.TransportError("close", "app-server transport closed by caller"));
125
163
  for (const [, pending] of this.pending) {
126
164
  clearTimeout(pending.timer);
127
165
  pending.reject(new _flowy_agent_store_protocol.TransportError("close", "app-server transport closed by caller"));
128
166
  }
129
167
  this.pending.clear();
168
+ this.listeners.clear();
169
+ this.lifecycle = null;
170
+ }
171
+ setLifecycle(state) {
172
+ if (state === "closed" && this.lifecycle !== "open") return;
173
+ if (this.lifecycle === state) return;
174
+ this.lifecycle = state;
175
+ for (const listener of [...this.lifecycleListeners]) try {
176
+ listener(state);
177
+ } catch {}
178
+ }
179
+ /** Detach the current socket (if any) and close it; its later events are ignored. */
180
+ detachSocket(socket) {
181
+ if (!socket) return;
182
+ if (this.socket === socket) this.socket = null;
183
+ try {
184
+ socket.close();
185
+ } catch {}
186
+ }
187
+ /** Settle the in-flight `connect()` successfully, when there is one. */
188
+ resolveConnect() {
189
+ const connecting = this.connecting;
190
+ if (!connecting) return;
191
+ this.connecting = null;
192
+ clearTimeout(connecting.timer);
193
+ connecting.resolve();
194
+ }
195
+ /** Fail the in-flight `connect()`, when there is one. */
196
+ rejectConnect(error) {
197
+ const connecting = this.connecting;
198
+ if (!connecting) return;
199
+ this.connecting = null;
200
+ clearTimeout(connecting.timer);
201
+ connecting.reject(error);
130
202
  }
131
203
  requireSocket() {
132
204
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) throw new _flowy_agent_store_protocol.TransportError("send", "app-server transport is not connected", { retryable: true });
@@ -281,6 +353,21 @@ var ConversationSubscription = class {
281
353
  await this.transport.request("conversation/unsubscribe", { conversation_id: this.conversationId });
282
354
  } catch {}
283
355
  }
356
+ /**
357
+ * Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
358
+ * notification listener, reset the cursor and re-issue
359
+ * `conversation/subscribe`. This subscription has no event-replay API, so the
360
+ * caller must still backfill the transcript for the outage window
361
+ * (`conversation/messages`). Call it once the handshake completed; a no-op
362
+ * once closed.
363
+ */
364
+ async rearm() {
365
+ if (this.closed) return;
366
+ this.removeListener?.();
367
+ this.removeListener = this.transport.onNotification((notification) => this.dispatch(notification));
368
+ this.lastSeenSequence = 0;
369
+ await this.transport.request("conversation/subscribe", { conversation_id: this.conversationId });
370
+ }
284
371
  dispatch(notification) {
285
372
  if (notification.method === "conversation/event") {
286
373
  const event = notification.params;
@@ -520,6 +607,33 @@ var EventSubscription = class {
520
607
  if (this.resyncInFlight === task) this.resyncInFlight = null;
521
608
  }
522
609
  }
610
+ /**
611
+ * Re-arm after the connection was re-established (docs/agent-store/16 T8).
612
+ * A new socket means the server dropped its subscriptions and the transport
613
+ * dropped its notification listeners, so this:
614
+ *
615
+ * 1. re-registers this subscription's notification listener,
616
+ * 2. resets the cursor to 0 and forgets the dedupe set, then
617
+ * 3. re-issues `run/subscribe` and replays **all** persisted events.
618
+ *
619
+ * The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
620
+ * consumer must dedupe by `sequence`, since events seen before the outage
621
+ * are delivered again. Call it only once the session handshake has completed
622
+ * again — the transport's `open` event fires before `initialize`. A no-op
623
+ * once closed.
624
+ */
625
+ async rearm() {
626
+ if (this.closed || !this.fetchAfter) return [];
627
+ this.unsubscribeNotification?.();
628
+ this.unsubscribeNotification = this.transport.onNotification((notification) => {
629
+ this.dispatch(notification);
630
+ });
631
+ this.lastSeenSequence = 0;
632
+ this.seenSequences.clear();
633
+ this.resyncInFlight = null;
634
+ await this.transport.request("run/subscribe", { run_id: this.runId });
635
+ return this.resync();
636
+ }
523
637
  markSeen(sequence) {
524
638
  this.seenSequences.add(sequence);
525
639
  if (sequence > this.lastSeenSequence) this.lastSeenSequence = sequence;
@@ -666,6 +780,8 @@ var AppServerClient = class {
666
780
  capabilities;
667
781
  initializeResult = null;
668
782
  notificationListeners = /* @__PURE__ */ new Set();
783
+ /** Transport → client notification bridge; re-armed after a transport close. */
784
+ unbridgeNotifications = null;
669
785
  constructor(options) {
670
786
  this.clientInfo = options.client;
671
787
  this.capabilities = options.capabilities;
@@ -678,7 +794,16 @@ var AppServerClient = class {
678
794
  this.agents = new AgentClient(this.transport);
679
795
  this.teams = new TeamClient(this.transport);
680
796
  this.models = new ModelClient(this.transport);
681
- this.transport.onNotification((notification) => {
797
+ this.bridgeNotifications();
798
+ }
799
+ /**
800
+ * Subscribe the client-level fan-out on the transport exactly once.
801
+ * `transport.close()` drops its listeners, so this is also called on
802
+ * `connect()`: otherwise a reconnect would leave notifications silent.
803
+ */
804
+ bridgeNotifications() {
805
+ if (this.unbridgeNotifications) return;
806
+ this.unbridgeNotifications = this.transport.onNotification((notification) => {
682
807
  for (const listener of [...this.notificationListeners]) try {
683
808
  listener(notification);
684
809
  } catch {}
@@ -692,6 +817,7 @@ var AppServerClient = class {
692
817
  }
693
818
  /** Connect and perform the initialize/initialized handshake. */
694
819
  async connect() {
820
+ this.bridgeNotifications();
695
821
  await this.transport.connect();
696
822
  const request = {
697
823
  protocol_version: _flowy_agent_store_protocol.APP_SERVER_PROTOCOL_VERSION,
@@ -712,6 +838,8 @@ var AppServerClient = class {
712
838
  }
713
839
  /** Close the transport; the server revokes the connection immediately. */
714
840
  close() {
841
+ this.unbridgeNotifications?.();
842
+ this.unbridgeNotifications = null;
715
843
  this.transport.close();
716
844
  this.initializeResult = null;
717
845
  this.notificationListeners.clear();
package/dist/index.d.cts CHANGED
@@ -15,15 +15,30 @@ export declare function isLoopbackUrl(raw: string): boolean;
15
15
  * so business code never knows whether it runs over WebSocket (browser),
16
16
  * stdio (Node/CLI, future) or a one-shot HTTP binding.
17
17
  */
18
+ /**
19
+ * Lifecycle of the underlying channel. `open` is emitted on every successful
20
+ * dial (the first one included), `closed` only when an established connection
21
+ * is lost — so `closed` → `open` is exactly a reconnect.
22
+ */
23
+ export type TransportLifecycle = "open" | "closed";
24
+ export type TransportLifecycleListener = (state: TransportLifecycle) => void;
18
25
  export interface Transport {
19
26
  connect(): Promise<void>;
20
27
  request<T>(method: string, params: unknown): Promise<T>;
21
28
  notify(method: string, params: unknown): void;
22
29
  onNotification(listener: NotificationListener): () => void;
23
30
  close(): void;
31
+ /**
32
+ * Optional: observe the channel lifecycle so a host can show a disconnect
33
+ * banner and re-arm subscriptions on reconnect (docs/agent-store/16 T8/W8).
34
+ * Custom transports may omit it.
35
+ */
36
+ onLifecycle?(listener: TransportLifecycleListener): () => void;
24
37
  }
25
38
  export interface TransportOptions {
26
39
  requestTimeoutMs?: number;
40
+ /** Max time `connect()` may stay unopened before failing (default 10s). */
41
+ connectTimeoutMs?: number;
27
42
  token?: string;
28
43
  }
29
44
  export declare class WebSocketTransport implements Transport {
@@ -31,10 +46,13 @@ export declare class WebSocketTransport implements Transport {
31
46
  private nextId;
32
47
  private pending;
33
48
  private listeners;
34
- private connectResolvers;
35
- private connectRejecters;
49
+ private lifecycleListeners;
50
+ private lifecycle;
36
51
  private closedByCaller;
52
+ /** Single in-flight connect shared by concurrent callers; null once settled. */
53
+ private connecting;
37
54
  readonly requestTimeoutMs: number;
55
+ readonly connectTimeoutMs: number;
38
56
  readonly url: string;
39
57
  readonly token?: string;
40
58
  constructor(url: string, options?: TransportOptions);
@@ -43,7 +61,22 @@ export declare class WebSocketTransport implements Transport {
43
61
  request<T>(method: string, params: unknown): Promise<T>;
44
62
  notify(method: string, params: unknown): void;
45
63
  onNotification(listener: NotificationListener): () => void;
64
+ /**
65
+ * Observe the channel lifecycle. `closed` is only reported when an
66
+ * established connection is lost (a failed first dial is not a disconnect),
67
+ * and `open` follows it on the next successful dial — that pair is what a
68
+ * host should treat as a reconnect. Unlike `onNotification`, these listeners
69
+ * survive `close()`, since they exist to drive the reconnection itself.
70
+ */
71
+ onLifecycle(listener: TransportLifecycleListener): () => void;
46
72
  close(): void;
73
+ private setLifecycle;
74
+ /** Detach the current socket (if any) and close it; its later events are ignored. */
75
+ private detachSocket;
76
+ /** Settle the in-flight `connect()` successfully, when there is one. */
77
+ private resolveConnect;
78
+ /** Fail the in-flight `connect()`, when there is one. */
79
+ private rejectConnect;
47
80
  private requireSocket;
48
81
  private handleMessage;
49
82
  private failAllPending;
@@ -92,6 +125,15 @@ export declare class ConversationSubscription {
92
125
  onEvent(listener: ConversationEventListener): () => void;
93
126
  onResync(listener: ConversationResyncListener): () => void;
94
127
  close(): Promise<void>;
128
+ /**
129
+ * Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
130
+ * notification listener, reset the cursor and re-issue
131
+ * `conversation/subscribe`. This subscription has no event-replay API, so the
132
+ * caller must still backfill the transcript for the outage window
133
+ * (`conversation/messages`). Call it once the handshake completed; a no-op
134
+ * once closed.
135
+ */
136
+ rearm(): Promise<void>;
95
137
  private dispatch;
96
138
  }
97
139
  //#endregion
@@ -184,6 +226,22 @@ export declare class EventSubscription {
184
226
  * Concurrent calls share one flight.
185
227
  */
186
228
  resync(): Promise<RunEvent[]>;
229
+ /**
230
+ * Re-arm after the connection was re-established (docs/agent-store/16 T8).
231
+ * A new socket means the server dropped its subscriptions and the transport
232
+ * dropped its notification listeners, so this:
233
+ *
234
+ * 1. re-registers this subscription's notification listener,
235
+ * 2. resets the cursor to 0 and forgets the dedupe set, then
236
+ * 3. re-issues `run/subscribe` and replays **all** persisted events.
237
+ *
238
+ * The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
239
+ * consumer must dedupe by `sequence`, since events seen before the outage
240
+ * are delivered again. Call it only once the session handshake has completed
241
+ * again — the transport's `open` event fires before `initialize`. A no-op
242
+ * once closed.
243
+ */
244
+ rearm(): Promise<RunEvent[]>;
187
245
  private markSeen;
188
246
  private emitEvent;
189
247
  private emitError;
@@ -269,7 +327,15 @@ export declare class AppServerClient {
269
327
  readonly capabilities?: ClientCapabilities;
270
328
  private initializeResult;
271
329
  private notificationListeners;
330
+ /** Transport → client notification bridge; re-armed after a transport close. */
331
+ private unbridgeNotifications;
272
332
  constructor(options: AppServerClientOptions);
333
+ /**
334
+ * Subscribe the client-level fan-out on the transport exactly once.
335
+ * `transport.close()` drops its listeners, so this is also called on
336
+ * `connect()`: otherwise a reconnect would leave notifications silent.
337
+ */
338
+ private bridgeNotifications;
273
339
  get ready(): boolean;
274
340
  get initializeInfo(): InitializeResult | null;
275
341
  /** Connect and perform the initialize/initialized handshake. */
package/dist/index.d.mts CHANGED
@@ -15,15 +15,30 @@ export declare function isLoopbackUrl(raw: string): boolean;
15
15
  * so business code never knows whether it runs over WebSocket (browser),
16
16
  * stdio (Node/CLI, future) or a one-shot HTTP binding.
17
17
  */
18
+ /**
19
+ * Lifecycle of the underlying channel. `open` is emitted on every successful
20
+ * dial (the first one included), `closed` only when an established connection
21
+ * is lost — so `closed` → `open` is exactly a reconnect.
22
+ */
23
+ export type TransportLifecycle = "open" | "closed";
24
+ export type TransportLifecycleListener = (state: TransportLifecycle) => void;
18
25
  export interface Transport {
19
26
  connect(): Promise<void>;
20
27
  request<T>(method: string, params: unknown): Promise<T>;
21
28
  notify(method: string, params: unknown): void;
22
29
  onNotification(listener: NotificationListener): () => void;
23
30
  close(): void;
31
+ /**
32
+ * Optional: observe the channel lifecycle so a host can show a disconnect
33
+ * banner and re-arm subscriptions on reconnect (docs/agent-store/16 T8/W8).
34
+ * Custom transports may omit it.
35
+ */
36
+ onLifecycle?(listener: TransportLifecycleListener): () => void;
24
37
  }
25
38
  export interface TransportOptions {
26
39
  requestTimeoutMs?: number;
40
+ /** Max time `connect()` may stay unopened before failing (default 10s). */
41
+ connectTimeoutMs?: number;
27
42
  token?: string;
28
43
  }
29
44
  export declare class WebSocketTransport implements Transport {
@@ -31,10 +46,13 @@ export declare class WebSocketTransport implements Transport {
31
46
  private nextId;
32
47
  private pending;
33
48
  private listeners;
34
- private connectResolvers;
35
- private connectRejecters;
49
+ private lifecycleListeners;
50
+ private lifecycle;
36
51
  private closedByCaller;
52
+ /** Single in-flight connect shared by concurrent callers; null once settled. */
53
+ private connecting;
37
54
  readonly requestTimeoutMs: number;
55
+ readonly connectTimeoutMs: number;
38
56
  readonly url: string;
39
57
  readonly token?: string;
40
58
  constructor(url: string, options?: TransportOptions);
@@ -43,7 +61,22 @@ export declare class WebSocketTransport implements Transport {
43
61
  request<T>(method: string, params: unknown): Promise<T>;
44
62
  notify(method: string, params: unknown): void;
45
63
  onNotification(listener: NotificationListener): () => void;
64
+ /**
65
+ * Observe the channel lifecycle. `closed` is only reported when an
66
+ * established connection is lost (a failed first dial is not a disconnect),
67
+ * and `open` follows it on the next successful dial — that pair is what a
68
+ * host should treat as a reconnect. Unlike `onNotification`, these listeners
69
+ * survive `close()`, since they exist to drive the reconnection itself.
70
+ */
71
+ onLifecycle(listener: TransportLifecycleListener): () => void;
46
72
  close(): void;
73
+ private setLifecycle;
74
+ /** Detach the current socket (if any) and close it; its later events are ignored. */
75
+ private detachSocket;
76
+ /** Settle the in-flight `connect()` successfully, when there is one. */
77
+ private resolveConnect;
78
+ /** Fail the in-flight `connect()`, when there is one. */
79
+ private rejectConnect;
47
80
  private requireSocket;
48
81
  private handleMessage;
49
82
  private failAllPending;
@@ -92,6 +125,15 @@ export declare class ConversationSubscription {
92
125
  onEvent(listener: ConversationEventListener): () => void;
93
126
  onResync(listener: ConversationResyncListener): () => void;
94
127
  close(): Promise<void>;
128
+ /**
129
+ * Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
130
+ * notification listener, reset the cursor and re-issue
131
+ * `conversation/subscribe`. This subscription has no event-replay API, so the
132
+ * caller must still backfill the transcript for the outage window
133
+ * (`conversation/messages`). Call it once the handshake completed; a no-op
134
+ * once closed.
135
+ */
136
+ rearm(): Promise<void>;
95
137
  private dispatch;
96
138
  }
97
139
  //#endregion
@@ -184,6 +226,22 @@ export declare class EventSubscription {
184
226
  * Concurrent calls share one flight.
185
227
  */
186
228
  resync(): Promise<RunEvent[]>;
229
+ /**
230
+ * Re-arm after the connection was re-established (docs/agent-store/16 T8).
231
+ * A new socket means the server dropped its subscriptions and the transport
232
+ * dropped its notification listeners, so this:
233
+ *
234
+ * 1. re-registers this subscription's notification listener,
235
+ * 2. resets the cursor to 0 and forgets the dedupe set, then
236
+ * 3. re-issues `run/subscribe` and replays **all** persisted events.
237
+ *
238
+ * The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
239
+ * consumer must dedupe by `sequence`, since events seen before the outage
240
+ * are delivered again. Call it only once the session handshake has completed
241
+ * again — the transport's `open` event fires before `initialize`. A no-op
242
+ * once closed.
243
+ */
244
+ rearm(): Promise<RunEvent[]>;
187
245
  private markSeen;
188
246
  private emitEvent;
189
247
  private emitError;
@@ -269,7 +327,15 @@ export declare class AppServerClient {
269
327
  readonly capabilities?: ClientCapabilities;
270
328
  private initializeResult;
271
329
  private notificationListeners;
330
+ /** Transport → client notification bridge; re-armed after a transport close. */
331
+ private unbridgeNotifications;
272
332
  constructor(options: AppServerClientOptions);
333
+ /**
334
+ * Subscribe the client-level fan-out on the transport exactly once.
335
+ * `transport.close()` drops its listeners, so this is also called on
336
+ * `connect()`: otherwise a reconnect would leave notifications silent.
337
+ */
338
+ private bridgeNotifications;
273
339
  get ready(): boolean;
274
340
  get initializeInfo(): InitializeResult | null;
275
341
  /** Connect and perform the initialize/initialized handshake. */
package/dist/index.mjs CHANGED
@@ -25,49 +25,74 @@ function isLoopbackUrl(raw) {
25
25
  return false;
26
26
  }
27
27
  }
28
+ /** How long an unopened `connect()` waits before it fails. */
29
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1e4;
28
30
  var WebSocketTransport = class {
29
31
  socket = null;
30
32
  nextId = 1;
31
33
  pending = /* @__PURE__ */ new Map();
32
34
  listeners = /* @__PURE__ */ new Set();
33
- connectResolvers = [];
34
- connectRejecters = [];
35
+ lifecycleListeners = /* @__PURE__ */ new Set();
36
+ lifecycle = null;
35
37
  closedByCaller = false;
38
+ /** Single in-flight connect shared by concurrent callers; null once settled. */
39
+ connecting = null;
36
40
  requestTimeoutMs;
41
+ connectTimeoutMs;
37
42
  url;
38
43
  token;
39
44
  constructor(url, options = {}) {
40
45
  this.url = url;
41
46
  this.token = options.token;
42
47
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
48
+ this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
43
49
  }
44
50
  get connected() {
45
51
  return this.socket?.readyState === WebSocket.OPEN;
46
52
  }
47
53
  connect() {
48
54
  if (this.connected) return Promise.resolve();
55
+ if (this.connecting) return this.connecting.promise;
49
56
  this.closedByCaller = false;
50
57
  const target = this.token ? `${this.url}${this.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.token)}` : this.url;
51
58
  const socket = new WebSocket(target);
52
59
  this.socket = socket;
60
+ let resolveConnect;
61
+ let rejectConnect;
62
+ const promise = new Promise((resolve, reject) => {
63
+ resolveConnect = resolve;
64
+ rejectConnect = reject;
65
+ });
66
+ const timer = setTimeout(() => {
67
+ this.detachSocket(socket);
68
+ this.rejectConnect(new TransportError("connect", `app-server connect timed out after ${this.connectTimeoutMs}ms`, { retryable: true }));
69
+ }, this.connectTimeoutMs);
70
+ this.connecting = {
71
+ promise,
72
+ resolve: resolveConnect,
73
+ reject: rejectConnect,
74
+ timer
75
+ };
53
76
  socket.onopen = () => {
54
- for (const resolve of this.connectResolvers.splice(0)) resolve();
77
+ if (this.socket !== socket) return;
78
+ this.resolveConnect();
79
+ this.setLifecycle("open");
55
80
  };
56
81
  socket.onmessage = (event) => {
82
+ if (this.socket !== socket) return;
57
83
  this.handleMessage(event.data);
58
84
  };
59
85
  socket.onclose = (event) => {
86
+ if (this.socket !== socket) return;
60
87
  this.failAllPending(new TransportError("close", `app-server connection closed (code ${event.code})`, { retryable: !this.closedByCaller }));
61
88
  this.socket = null;
62
- for (const reject of this.connectRejecters.splice(0)) reject(new TransportError("connect", "app-server connection closed before opening"));
89
+ this.setLifecycle("closed");
90
+ this.rejectConnect(new TransportError("connect", "app-server connection closed before opening"));
63
91
  };
64
92
  socket.onerror = () => {
65
- this.socket?.close();
93
+ if (this.socket === socket) socket.close();
66
94
  };
67
- return new Promise((resolve, reject) => {
68
- this.connectResolvers.push(resolve);
69
- this.connectRejecters.push(reject);
70
- });
95
+ return promise;
71
96
  }
72
97
  request(method, params) {
73
98
  const socket = this.requireSocket();
@@ -117,15 +142,62 @@ var WebSocketTransport = class {
117
142
  this.listeners.delete(listener);
118
143
  };
119
144
  }
145
+ /**
146
+ * Observe the channel lifecycle. `closed` is only reported when an
147
+ * established connection is lost (a failed first dial is not a disconnect),
148
+ * and `open` follows it on the next successful dial — that pair is what a
149
+ * host should treat as a reconnect. Unlike `onNotification`, these listeners
150
+ * survive `close()`, since they exist to drive the reconnection itself.
151
+ */
152
+ onLifecycle(listener) {
153
+ this.lifecycleListeners.add(listener);
154
+ return () => {
155
+ this.lifecycleListeners.delete(listener);
156
+ };
157
+ }
120
158
  close() {
121
159
  this.closedByCaller = true;
122
- this.socket?.close();
123
- this.socket = null;
160
+ this.detachSocket(this.socket);
161
+ this.rejectConnect(new TransportError("close", "app-server transport closed by caller"));
124
162
  for (const [, pending] of this.pending) {
125
163
  clearTimeout(pending.timer);
126
164
  pending.reject(new TransportError("close", "app-server transport closed by caller"));
127
165
  }
128
166
  this.pending.clear();
167
+ this.listeners.clear();
168
+ this.lifecycle = null;
169
+ }
170
+ setLifecycle(state) {
171
+ if (state === "closed" && this.lifecycle !== "open") return;
172
+ if (this.lifecycle === state) return;
173
+ this.lifecycle = state;
174
+ for (const listener of [...this.lifecycleListeners]) try {
175
+ listener(state);
176
+ } catch {}
177
+ }
178
+ /** Detach the current socket (if any) and close it; its later events are ignored. */
179
+ detachSocket(socket) {
180
+ if (!socket) return;
181
+ if (this.socket === socket) this.socket = null;
182
+ try {
183
+ socket.close();
184
+ } catch {}
185
+ }
186
+ /** Settle the in-flight `connect()` successfully, when there is one. */
187
+ resolveConnect() {
188
+ const connecting = this.connecting;
189
+ if (!connecting) return;
190
+ this.connecting = null;
191
+ clearTimeout(connecting.timer);
192
+ connecting.resolve();
193
+ }
194
+ /** Fail the in-flight `connect()`, when there is one. */
195
+ rejectConnect(error) {
196
+ const connecting = this.connecting;
197
+ if (!connecting) return;
198
+ this.connecting = null;
199
+ clearTimeout(connecting.timer);
200
+ connecting.reject(error);
129
201
  }
130
202
  requireSocket() {
131
203
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) throw new TransportError("send", "app-server transport is not connected", { retryable: true });
@@ -280,6 +352,21 @@ var ConversationSubscription = class {
280
352
  await this.transport.request("conversation/unsubscribe", { conversation_id: this.conversationId });
281
353
  } catch {}
282
354
  }
355
+ /**
356
+ * Re-arm after a reconnect (docs/agent-store/16 T8): re-register the
357
+ * notification listener, reset the cursor and re-issue
358
+ * `conversation/subscribe`. This subscription has no event-replay API, so the
359
+ * caller must still backfill the transcript for the outage window
360
+ * (`conversation/messages`). Call it once the handshake completed; a no-op
361
+ * once closed.
362
+ */
363
+ async rearm() {
364
+ if (this.closed) return;
365
+ this.removeListener?.();
366
+ this.removeListener = this.transport.onNotification((notification) => this.dispatch(notification));
367
+ this.lastSeenSequence = 0;
368
+ await this.transport.request("conversation/subscribe", { conversation_id: this.conversationId });
369
+ }
283
370
  dispatch(notification) {
284
371
  if (notification.method === "conversation/event") {
285
372
  const event = notification.params;
@@ -519,6 +606,33 @@ var EventSubscription = class {
519
606
  if (this.resyncInFlight === task) this.resyncInFlight = null;
520
607
  }
521
608
  }
609
+ /**
610
+ * Re-arm after the connection was re-established (docs/agent-store/16 T8).
611
+ * A new socket means the server dropped its subscriptions and the transport
612
+ * dropped its notification listeners, so this:
613
+ *
614
+ * 1. re-registers this subscription's notification listener,
615
+ * 2. resets the cursor to 0 and forgets the dedupe set, then
616
+ * 3. re-issues `run/subscribe` and replays **all** persisted events.
617
+ *
618
+ * The replay is deliberate (the plan's "重连后重置 lastSeenSequence"): a
619
+ * consumer must dedupe by `sequence`, since events seen before the outage
620
+ * are delivered again. Call it only once the session handshake has completed
621
+ * again — the transport's `open` event fires before `initialize`. A no-op
622
+ * once closed.
623
+ */
624
+ async rearm() {
625
+ if (this.closed || !this.fetchAfter) return [];
626
+ this.unsubscribeNotification?.();
627
+ this.unsubscribeNotification = this.transport.onNotification((notification) => {
628
+ this.dispatch(notification);
629
+ });
630
+ this.lastSeenSequence = 0;
631
+ this.seenSequences.clear();
632
+ this.resyncInFlight = null;
633
+ await this.transport.request("run/subscribe", { run_id: this.runId });
634
+ return this.resync();
635
+ }
522
636
  markSeen(sequence) {
523
637
  this.seenSequences.add(sequence);
524
638
  if (sequence > this.lastSeenSequence) this.lastSeenSequence = sequence;
@@ -665,6 +779,8 @@ var AppServerClient = class {
665
779
  capabilities;
666
780
  initializeResult = null;
667
781
  notificationListeners = /* @__PURE__ */ new Set();
782
+ /** Transport → client notification bridge; re-armed after a transport close. */
783
+ unbridgeNotifications = null;
668
784
  constructor(options) {
669
785
  this.clientInfo = options.client;
670
786
  this.capabilities = options.capabilities;
@@ -677,7 +793,16 @@ var AppServerClient = class {
677
793
  this.agents = new AgentClient(this.transport);
678
794
  this.teams = new TeamClient(this.transport);
679
795
  this.models = new ModelClient(this.transport);
680
- this.transport.onNotification((notification) => {
796
+ this.bridgeNotifications();
797
+ }
798
+ /**
799
+ * Subscribe the client-level fan-out on the transport exactly once.
800
+ * `transport.close()` drops its listeners, so this is also called on
801
+ * `connect()`: otherwise a reconnect would leave notifications silent.
802
+ */
803
+ bridgeNotifications() {
804
+ if (this.unbridgeNotifications) return;
805
+ this.unbridgeNotifications = this.transport.onNotification((notification) => {
681
806
  for (const listener of [...this.notificationListeners]) try {
682
807
  listener(notification);
683
808
  } catch {}
@@ -691,6 +816,7 @@ var AppServerClient = class {
691
816
  }
692
817
  /** Connect and perform the initialize/initialized handshake. */
693
818
  async connect() {
819
+ this.bridgeNotifications();
694
820
  await this.transport.connect();
695
821
  const request = {
696
822
  protocol_version: APP_SERVER_PROTOCOL_VERSION,
@@ -711,6 +837,8 @@ var AppServerClient = class {
711
837
  }
712
838
  /** Close the transport; the server revokes the connection immediately. */
713
839
  close() {
840
+ this.unbridgeNotifications?.();
841
+ this.unbridgeNotifications = null;
714
842
  this.transport.close();
715
843
  this.initializeResult = null;
716
844
  this.notificationListeners.clear();
package/package.json CHANGED
@@ -1,32 +1,41 @@
1
- {
2
- "name": "@flowy-agent-store/client",
3
- "version": "0.1.0-beta.2",
4
- "description": "Transport-agnostic App Server client (docs/agent-store/07 \u00a72.2)",
5
- "type": "module",
6
- "license": "Apache-2.0",
7
- "exports": {
8
- ".": {
9
- "types": "./dist/index.d.mts",
10
- "import": "./dist/index.mjs",
11
- "require": "./dist/index.cjs"
12
- },
13
- "./package.json": "./package.json"
14
- },
15
- "files": [
16
- "dist"
17
- ],
18
- "dependencies": {
19
- "@flowy-agent-store/protocol": "0.1.0-beta.2"
20
- },
21
- "scripts": {
22
- "build": "tsdown",
23
- "prepack": "tsdown"
24
- },
25
- "publishConfig": {
26
- "access": "public",
27
- "registry": "https://registry.npmjs.org/"
28
- },
29
- "main": "./dist/index.cjs",
30
- "module": "./dist/index.mjs",
31
- "types": "./dist/index.d.mts"
32
- }
1
+ {
2
+ "name": "@flowy-agent-store/client",
3
+ "version": "0.1.0-beta.3",
4
+ "description": "Transport-agnostic App Server client (docs/agent-store/07 §2.2)",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Michael-Lfx/allo.git",
13
+ "directory": "web/packages/client"
14
+ },
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.mts",
19
+ "import": "./dist/index.mjs",
20
+ "require": "./dist/index.cjs"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@flowy-agent-store/protocol": "0.1.0-beta.3"
29
+ },
30
+ "scripts": {
31
+ "build": "tsdown",
32
+ "prepack": "tsdown"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
37
+ },
38
+ "main": "./dist/index.cjs",
39
+ "module": "./dist/index.mjs",
40
+ "types": "./dist/index.d.mts"
41
+ }