@adhdev/session-host-core 1.0.82-rc.24 → 1.0.82-rc.26

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/ipc.ts CHANGED
@@ -4,6 +4,7 @@ import * as net from 'net';
4
4
  import { generateUUID } from './uuid.js';
5
5
  import { StringDecoder } from 'string_decoder';
6
6
  import type {
7
+ AttachSessionPayload,
7
8
  SessionHostEvent,
8
9
  SessionHostRequest,
9
10
  SessionHostRequestEnvelope,
@@ -122,6 +123,7 @@ function createLineParser<TEnvelope = SessionHostWireEnvelope>(
122
123
  export interface SessionHostClientOptions {
123
124
  endpoint?: SessionHostEndpoint;
124
125
  appName?: string;
126
+ requestTimeoutMs?: number;
125
127
  }
126
128
 
127
129
  /**
@@ -138,6 +140,20 @@ export type SessionHostDisconnectReason = 'error' | 'ended' | 'closed';
138
140
  * (host not up yet) — only the former is a session-host death signal.
139
141
  */
140
142
  const establishedSockets = new WeakSet<net.Socket>();
143
+ const activeClientRefs = new Set<WeakRef<SessionHostClient>>();
144
+
145
+ export function disconnectSessionHostClients(endpoint?: SessionHostEndpoint, error?: Error): void {
146
+ for (const ref of activeClientRefs) {
147
+ const client = ref.deref();
148
+ if (!client) {
149
+ activeClientRefs.delete(ref);
150
+ continue;
151
+ }
152
+ if (!endpoint || client.endpoint.path === endpoint.path) {
153
+ client.disconnect(error);
154
+ }
155
+ }
156
+ }
141
157
 
142
158
  export interface SessionHostDisconnectInfo {
143
159
  reason: SessionHostDisconnectReason;
@@ -161,14 +177,26 @@ export class SessionHostClient {
161
177
  readonly endpoint: SessionHostEndpoint;
162
178
 
163
179
  private socket: net.Socket | null = null;
180
+ private connectPromise: Promise<void> | null = null;
181
+ private requestTimeoutMs = 30_000;
164
182
  private requestWaiters = new Map<string, { resolve: (value: SessionHostResponse) => void; reject: (error: Error) => void }>();
165
183
  private eventListeners = new Set<(event: SessionHostEvent) => void>();
166
184
  private disconnectListeners = new Set<(info: SessionHostDisconnectInfo) => void>();
167
185
  /** Guards against emitting both an 'error'-path and a 'close'-path disconnect for one socket. */
168
186
  private disconnectedSockets = new WeakSet<net.Socket>();
187
+ /** Session subscriptions established via create_session/attach_session, replayed automatically on reconnect. */
188
+ private attachedSessions = new Map<string, AttachSessionPayload>();
169
189
 
170
190
  constructor(options: SessionHostClientOptions = {}) {
171
191
  this.endpoint = options.endpoint || getDefaultSessionHostEndpoint(options.appName || 'adhdev');
192
+ if (typeof options.requestTimeoutMs === 'number' && options.requestTimeoutMs > 0) {
193
+ this.requestTimeoutMs = options.requestTimeoutMs;
194
+ }
195
+ activeClientRefs.add(new WeakRef(this));
196
+ }
197
+
198
+ static disconnectClients(endpoint?: SessionHostEndpoint, error?: Error): void {
199
+ disconnectSessionHostClients(endpoint, error);
172
200
  }
173
201
 
174
202
  /**
@@ -228,6 +256,7 @@ export class SessionHostClient {
228
256
  // guard at the top of connect().
229
257
  if (this.socket === socket) {
230
258
  this.socket = null;
259
+ this.connectPromise = null;
231
260
  }
232
261
  try { socket.destroy(); } catch { /* noop */ }
233
262
 
@@ -246,13 +275,49 @@ export class SessionHostClient {
246
275
  }
247
276
  }
248
277
 
278
+ private sendRawRequestOnSocket<T = unknown>(
279
+ socket: net.Socket,
280
+ request: SessionHostRequest,
281
+ ): Promise<SessionHostResponse<T>> {
282
+ const requestId = generateUUID();
283
+ const envelope: SessionHostRequestEnvelope = {
284
+ kind: 'request',
285
+ requestId,
286
+ request,
287
+ };
288
+ const timeoutMs = this.requestTimeoutMs > 0 ? this.requestTimeoutMs : 30_000;
289
+ const timeoutLabel = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
290
+ return new Promise<SessionHostResponse<T>>((resolve, reject) => {
291
+ const timeout = setTimeout(() => {
292
+ const timeoutError = new Error(`Session host request timed out after ${timeoutLabel} (${request.type})`);
293
+ if (this.socket === socket) {
294
+ this.disconnect(timeoutError);
295
+ } else {
296
+ this.requestWaiters.delete(requestId);
297
+ this.disconnectedSockets.add(socket);
298
+ try { socket.destroy(); } catch { /* noop */ }
299
+ reject(timeoutError);
300
+ }
301
+ }, timeoutMs);
302
+ this.requestWaiters.set(requestId, {
303
+ resolve: (value) => { clearTimeout(timeout); resolve(value as SessionHostResponse<T>); },
304
+ reject: (error) => { clearTimeout(timeout); reject(error); },
305
+ });
306
+ socket.write(serializeEnvelope(envelope));
307
+ });
308
+ }
309
+
249
310
  async connect(): Promise<void> {
250
- if (this.socket && !this.socket.destroyed) return;
311
+ if (this.socket && !this.socket.destroyed) {
312
+ if (this.connectPromise) await this.connectPromise;
313
+ return;
314
+ }
251
315
  // Cleanup stale socket reference left after error/disconnect
252
316
  if (this.socket) {
253
317
  try { this.socket.destroy(); } catch { /* noop */ }
254
318
  this.socket = null;
255
319
  }
320
+ this.connectPromise = null;
256
321
 
257
322
  const socket = net.createConnection(this.endpoint.path);
258
323
  this.socket = socket;
@@ -314,13 +379,36 @@ export class SessionHostClient {
314
379
  this.handleDisconnect(socket, 'ended', undefined, flushParser().length);
315
380
  });
316
381
 
317
- await new Promise<void>((resolve, reject) => {
318
- socket.once('connect', () => {
319
- establishedSockets.add(socket);
320
- resolve();
382
+ const connectTask = (async () => {
383
+ await new Promise<void>((resolve, reject) => {
384
+ socket.once('connect', () => {
385
+ establishedSockets.add(socket);
386
+ resolve();
387
+ });
388
+ socket.once('error', reject);
321
389
  });
322
- socket.once('error', reject);
323
- });
390
+ if (this.attachedSessions.size > 0 && this.socket === socket && !socket.destroyed) {
391
+ for (const [sessionId, sub] of [...this.attachedSessions.entries()]) {
392
+ if (this.socket !== socket || socket.destroyed) break;
393
+ const replayResponse = await this.sendRawRequestOnSocket(socket, {
394
+ type: 'attach_session',
395
+ payload: sub,
396
+ }).catch(() => null);
397
+ if (replayResponse && !replayResponse.success) {
398
+ this.attachedSessions.delete(sessionId);
399
+ }
400
+ }
401
+ }
402
+ })();
403
+
404
+ this.connectPromise = connectTask;
405
+ try {
406
+ await connectTask;
407
+ } finally {
408
+ if (this.connectPromise === connectTask) {
409
+ this.connectPromise = null;
410
+ }
411
+ }
324
412
  }
325
413
 
326
414
  onEvent(listener: (event: SessionHostEvent) => void): () => void {
@@ -332,31 +420,53 @@ export class SessionHostClient {
332
420
 
333
421
  async request<T = unknown>(request: SessionHostRequest): Promise<SessionHostResponse<T>> {
334
422
  await this.connect();
335
- if (!this.socket) throw new Error('Session host socket unavailable');
336
-
337
- const requestId = generateUUID();
338
- const envelope: SessionHostRequestEnvelope = {
339
- kind: 'request',
340
- requestId,
341
- request,
342
- };
343
-
344
- const response = await new Promise<SessionHostResponse>((resolve, reject) => {
345
- const timeout = setTimeout(() => {
346
- this.requestWaiters.delete(requestId);
347
- reject(new Error(`Session host request timed out after 30s (${request.type})`));
348
- }, 30_000);
349
- this.requestWaiters.set(requestId, {
350
- resolve: (value) => { clearTimeout(timeout); resolve(value); },
351
- reject: (error) => { clearTimeout(timeout); reject(error); },
352
- });
353
- this.socket?.write(serializeEnvelope(envelope));
354
- });
423
+ const requestSocket = this.socket;
424
+ if (!requestSocket) throw new Error('Session host socket unavailable');
425
+
426
+ const response = await this.sendRawRequestOnSocket<T>(requestSocket, request);
427
+ if (response.success) {
428
+ if (request.type === 'attach_session') {
429
+ this.attachedSessions.set(request.payload.sessionId, {
430
+ sessionId: request.payload.sessionId,
431
+ clientId: request.payload.clientId,
432
+ clientType: request.payload.clientType,
433
+ readOnly: request.payload.readOnly === true,
434
+ });
435
+ } else if (request.type === 'create_session') {
436
+ const createdId = (response.result as { sessionId?: string } | undefined)?.sessionId || request.payload.sessionId;
437
+ const clientId = typeof request.payload.clientId === 'string' ? request.payload.clientId.trim() : '';
438
+ if (createdId && clientId) {
439
+ this.attachedSessions.set(createdId, {
440
+ sessionId: createdId,
441
+ clientId,
442
+ clientType: request.payload.clientType || 'daemon',
443
+ readOnly: false,
444
+ });
445
+ }
446
+ } else if (request.type === 'detach_session' || request.type === 'delete_session') {
447
+ this.attachedSessions.delete(request.payload.sessionId);
448
+ }
449
+ }
450
+ return response;
451
+ }
355
452
 
356
- return response as SessionHostResponse<T>;
453
+ disconnect(error?: Error): void {
454
+ const socket = this.socket;
455
+ if (socket) {
456
+ this.handleDisconnect(socket, error ? 'error' : 'closed', error, 0);
457
+ return;
458
+ }
459
+ this.connectPromise = null;
460
+ const failure = error || new Error(`Session host client disconnected (${this.endpoint.path})`);
461
+ for (const waiter of this.requestWaiters.values()) {
462
+ waiter.reject(failure);
463
+ }
464
+ this.requestWaiters.clear();
357
465
  }
358
466
 
359
467
  async close(): Promise<void> {
468
+ this.attachedSessions.clear();
469
+ this.connectPromise = null;
360
470
  if (!this.socket) return;
361
471
  const socket = this.socket;
362
472
  this.socket = null;