@soederpop/luca 0.0.26 → 0.0.28

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.
@@ -16,6 +16,10 @@ const DEFAULT_SOCKET_PATH = join(
16
16
  'ipc-window.sock'
17
17
  )
18
18
 
19
+ function controlPathFor(socketPath: string): string {
20
+ return socketPath.replace(/\.sock$/, '-control.sock')
21
+ }
22
+
19
23
  const ErrorCodes = ['BadRequest', 'NotFound', 'EvalFailed', 'Internal', 'Timeout', 'Disconnected', 'NoClient'] as const
20
24
  type WindowManagerErrorCode = typeof ErrorCodes[number]
21
25
 
@@ -45,6 +49,24 @@ export const WindowManagerOptionsSchema = FeatureOptionsSchema.extend({
45
49
  })
46
50
  export type WindowManagerOptions = z.infer<typeof WindowManagerOptionsSchema>
47
51
 
52
+ /** One window as tracked in feature state (keys are canonical lowercase ids). */
53
+ export const WindowTrackedEntrySchema = z.object({
54
+ windowId: z.string().describe('Canonical id (lowercase)'),
55
+ nativeWindowId: z.string().optional().describe('Last id string from the native app (may differ in casing)'),
56
+ openedAt: z.number().optional().describe('Epoch ms when this process first recorded the window'),
57
+ lastAck: z.any().optional().describe('JSON-serializable snapshot of the last relevant ack payload'),
58
+ kind: z.enum(['browser', 'terminal', 'unknown']).optional().describe('How the window was opened, if known'),
59
+ })
60
+ export type WindowTrackedEntry = z.infer<typeof WindowTrackedEntrySchema>
61
+
62
+ /** In-flight window IPC op (the Promise + timer remain internal to the feature). */
63
+ export const WindowPendingOperationSchema = z.object({
64
+ requestId: z.string(),
65
+ action: z.string().optional(),
66
+ startedAt: z.number(),
67
+ })
68
+ export type WindowPendingOperation = z.infer<typeof WindowPendingOperationSchema>
69
+
48
70
  export const WindowManagerStateSchema = FeatureStateSchema.extend({
49
71
  listening: z.boolean().default(false)
50
72
  .describe('Whether the IPC server is listening'),
@@ -53,9 +75,17 @@ export const WindowManagerStateSchema = FeatureStateSchema.extend({
53
75
  socketPath: z.string().optional()
54
76
  .describe('The socket path in use'),
55
77
  windowCount: z.number().default(0)
56
- .describe('Number of tracked windows'),
78
+ .describe('Number of tracked windows (mirrors keys of `windows`)'),
79
+ windows: z.record(z.string(), WindowTrackedEntrySchema).default({})
80
+ .describe('Open windows keyed by canonical id'),
81
+ pendingOperations: z.array(WindowPendingOperationSchema).default([])
82
+ .describe('Window commands awaiting windowAck'),
83
+ producerCount: z.number().default(0)
84
+ .describe('Producer sockets connected to this broker (broker mode only)'),
57
85
  lastError: z.string().optional()
58
86
  .describe('Last error message'),
87
+ mode: z.enum(['broker', 'producer']).optional()
88
+ .describe('Whether this instance is the broker (owns app socket) or a producer (routes through broker)'),
59
89
  })
60
90
  export type WindowManagerState = z.infer<typeof WindowManagerStateSchema>
61
91
 
@@ -65,8 +95,9 @@ export const WindowManagerEventsSchema = FeatureEventsSchema.extend({
65
95
  clientDisconnected: z.tuple([]).describe('Emitted when the native app disconnects'),
66
96
  message: z.tuple([z.any().describe('The parsed message object')]).describe('Emitted for any incoming message that is not a windowAck'),
67
97
  windowAck: z.tuple([z.any().describe('The window ack payload')]).describe('Emitted when a window ack is received from the app'),
68
- windowClosed: z.tuple([z.any().describe('Window lifecycle payload emitted when a window closes')]).describe('Emitted when the native app reports a window closed event'),
98
+ windowClosed: z.tuple([z.any().describe('Lifecycle payload; includes canonical lowercase `windowId` when the closed window can be inferred (from `windowId`, `id`, nested fields, etc.)')]).describe('Emitted when the native app reports a window closed event'),
69
99
  terminalExited: z.tuple([z.any().describe('Terminal lifecycle payload emitted when a terminal process exits')]).describe('Emitted when the native app reports a terminal process exit event'),
100
+ windowFocus: z.tuple([z.any().describe('Focus payload with windowId, kind, focused (boolean), and frame {x, y, width, height}')]).describe('Emitted when a window gains or loses focus'),
70
101
  error: z.tuple([z.any().describe('The error')]).describe('Emitted on error'),
71
102
  })
72
103
 
@@ -180,6 +211,8 @@ export type LayoutEntry =
180
211
  interface WindowHandleEvents {
181
212
  close: [msg: any]
182
213
  terminalExited: [msg: any]
214
+ focus: [msg: any]
215
+ blur: [msg: any]
183
216
  }
184
217
 
185
218
  // --- WindowHandle ---
@@ -221,6 +254,10 @@ export class WindowHandle {
221
254
  this._events.off(event, listener)
222
255
  return this
223
256
  }
257
+
258
+ async waitFor(event: string) {
259
+ return this._events.waitFor(event as any)
260
+ }
224
261
 
225
262
  /** Register a one-time listener for a lifecycle event. */
226
263
  once<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
@@ -282,8 +319,17 @@ interface ClientConnection {
282
319
  /**
283
320
  * WindowManager Feature — Native window control via LucaVoiceLauncher
284
321
  *
285
- * Acts as an IPC server that the native macOS launcher app connects to.
286
- * Communicates over a Unix domain socket using NDJSON (newline-delimited JSON).
322
+ * Uses a broker/producer architecture so multiple luca processes can trigger
323
+ * window operations without competing for the same Unix socket.
324
+ *
325
+ * **Architecture:**
326
+ * - The first process to call `listen()` becomes the **broker**. It owns
327
+ * the app-facing socket (`ipc-window.sock`) and a control socket
328
+ * (`ipc-window-control.sock`).
329
+ * - Subsequent processes detect the broker and become **producers**. They
330
+ * connect to the control socket and route commands through the broker.
331
+ * - The broker forwards producer commands to the native app and routes
332
+ * acks and lifecycle events back to the originating producer.
287
333
  *
288
334
  * **Protocol:**
289
335
  * - Bun listens on a Unix domain socket; the native app connects as a client
@@ -295,7 +341,16 @@ interface ClientConnection {
295
341
  * **Capabilities:**
296
342
  * - Spawn native browser windows with configurable chrome
297
343
  * - Navigate, focus, close, and eval JavaScript in windows
298
- * - Automatic socket file cleanup and fallback paths
344
+ * - Multiple luca processes can trigger window operations simultaneously
345
+ * - Automatic broker detection and producer fallback
346
+ *
347
+ * Observable state includes `windows` (open window metadata), `pendingOperations`
348
+ * (in-flight command ids), and `producerCount` (broker). Sockets, promises, and
349
+ * `WindowHandle` instances stay internal.
350
+ *
351
+ * **Producer state:** The broker pushes `windowStateSync` on the control socket when a
352
+ * producer connects and whenever the window roster changes, so every process sees the
353
+ * same `windows` / `windowCount` / `clientConnected` as the broker (not only its own acks).
299
354
  *
300
355
  * @example
301
356
  * ```typescript
@@ -321,59 +376,97 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
321
376
  static override eventsSchema = WindowManagerEventsSchema
322
377
  static { Feature.register(this, 'windowManager') }
323
378
 
324
- private _server?: NetServer
325
- private _client?: ClientConnection
379
+ // --- Shared state ---
326
380
  private _pending = new Map<string, PendingRequest>()
327
- private _trackedWindows = new Set<string>()
328
381
  private _handles = new Map<string, WindowHandle>()
382
+ private _mode: 'broker' | 'producer' | null = null
383
+
384
+ // --- Broker-only state ---
385
+ private _server?: NetServer // app-facing socket server
386
+ private _controlServer?: NetServer // producer-facing socket server
387
+ private _client?: ClientConnection // the connected native app
388
+ private _producers = new Map<string, ClientConnection>() // connected producer processes
389
+ private _requestOrigins = new Map<string, Socket>() // requestId → producer socket
390
+
391
+ // --- Producer-only state ---
392
+ private _controlClient?: ClientConnection // connection to broker's control socket
329
393
 
330
394
  private normalizeRequestId(value: unknown): string | undefined {
331
395
  if (typeof value !== 'string') return undefined
332
396
  return value.toLowerCase()
333
397
  }
334
398
 
335
- private waitForAnyClientConnection(timeoutMs: number, bridge?: any): Promise<'direct' | 'bridge' | undefined> {
336
- if (this._client) return Promise.resolve('direct')
337
- if (bridge?.isClientConnected) return Promise.resolve('bridge')
338
-
339
- return new Promise<'direct' | 'bridge' | undefined>((resolve) => {
340
- let settled = false
399
+ /** Lowercase map key for `_handles` so lookups match regardless of native id casing. */
400
+ private handleMapKey(value: unknown): string | undefined {
401
+ if (typeof value === 'number' && !Number.isNaN(value)) return String(value).toLowerCase()
402
+ return this.normalizeRequestId(value)
403
+ }
341
404
 
342
- const cleanup = () => {
343
- this.off('clientConnected', onDirectConnected)
344
- bridge?.off?.('clientConnected', onBridgeConnected)
345
- }
405
+ /**
406
+ * Best-effort window id from a native lifecycle message (field names differ by app version).
407
+ */
408
+ private extractLifecycleWindowId(msg: any): string | undefined {
409
+ if (!msg || typeof msg !== 'object') return undefined
410
+ const candidates = [
411
+ msg.windowId,
412
+ msg.window_id,
413
+ msg.windowID,
414
+ msg.window?.id,
415
+ msg.window?.windowId,
416
+ msg.payload?.windowId,
417
+ msg.result?.windowId,
418
+ msg.id,
419
+ ]
420
+ for (const c of candidates) {
421
+ if (typeof c === 'string' && c.length > 0) return c
422
+ if (typeof c === 'number' && !Number.isNaN(c)) return String(c)
423
+ }
424
+ return undefined
425
+ }
346
426
 
347
- const timer = setTimeout(() => {
348
- if (settled) return
349
- settled = true
350
- cleanup()
351
- resolve(undefined)
352
- }, timeoutMs)
427
+ /** Copy lifecycle message and set `windowId` to a canonical lowercase string when inferable. */
428
+ private enrichLifecycleWithCanonicalWindowId(msg: any): any {
429
+ const raw = this.extractLifecycleWindowId(msg)
430
+ if (raw === undefined) return msg
431
+ const key = this.handleMapKey(raw)
432
+ if (!key) return msg
433
+ return { ...msg, windowId: key }
434
+ }
353
435
 
354
- const onDirectConnected = () => {
355
- if (settled) return
356
- settled = true
357
- clearTimeout(timer)
358
- cleanup()
359
- resolve('direct')
360
- }
436
+ /** Structured clone via JSON for stashing ack snapshots in observable state. */
437
+ private snapshotForState(value: unknown): any {
438
+ if (value === undefined) return undefined
439
+ try {
440
+ return JSON.parse(JSON.stringify(value))
441
+ } catch {
442
+ return undefined
443
+ }
444
+ }
361
445
 
362
- const onBridgeConnected = () => {
363
- if (settled) return
364
- settled = true
365
- clearTimeout(timer)
366
- cleanup()
367
- resolve('bridge')
368
- }
446
+ private syncProducerCount(): void {
447
+ this.setState({ producerCount: this._producers.size })
448
+ }
369
449
 
370
- this.once('clientConnected', onDirectConnected)
371
- bridge?.once?.('clientConnected', onBridgeConnected)
372
- })
450
+ private registerPendingOperation(id: string, action: string | undefined, entry: PendingRequest): void {
451
+ this._pending.set(id, entry)
452
+ this.setState((cur) => ({
453
+ pendingOperations: [
454
+ ...(cur.pendingOperations ?? []),
455
+ { requestId: id, action, startedAt: Date.now() },
456
+ ],
457
+ }))
373
458
  }
374
459
 
375
- private getBridgeListener(): any | undefined {
376
- return undefined
460
+ /** Clears timer, drops from `_pending` and from `pendingOperations` state. */
461
+ private takePendingRequest(id: string): PendingRequest | undefined {
462
+ const pending = this._pending.get(id)
463
+ if (!pending) return undefined
464
+ clearTimeout(pending.timer)
465
+ this._pending.delete(id)
466
+ this.setState((cur) => ({
467
+ pendingOperations: (cur.pendingOperations ?? []).filter((o) => o.requestId !== id),
468
+ }))
469
+ return pending
377
470
  }
378
471
 
379
472
  /** Default state: not listening, no client connected, zero windows tracked. */
@@ -383,15 +476,28 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
383
476
  listening: false,
384
477
  clientConnected: false,
385
478
  windowCount: 0,
479
+ windows: {},
480
+ pendingOperations: [],
481
+ producerCount: 0,
386
482
  }
387
483
  }
388
484
 
389
- /** Whether the IPC server is currently listening. */
485
+ /** Whether this instance is acting as the broker. */
486
+ get isBroker(): boolean {
487
+ return this._mode === 'broker'
488
+ }
489
+
490
+ /** Whether this instance is acting as a producer. */
491
+ get isProducer(): boolean {
492
+ return this._mode === 'producer'
493
+ }
494
+
495
+ /** Whether the IPC server is currently listening (broker) or connected to broker (producer). */
390
496
  get isListening(): boolean {
391
497
  return this.state.get('listening') || false
392
498
  }
393
499
 
394
- /** Whether the native app client is currently connected. */
500
+ /** Whether the native app client is currently connected (only meaningful for broker). */
395
501
  get isClientConnected(): boolean {
396
502
  return this.state.get('clientConnected') || false
397
503
  }
@@ -400,24 +506,29 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
400
506
  await super.enable(options)
401
507
 
402
508
  if (this.options.autoListen) {
403
- this.listen()
509
+ await this.listen()
404
510
  }
405
511
 
406
512
  return this
407
513
  }
408
514
 
409
515
  /**
410
- * Start listening on the Unix domain socket for the native app to connect.
411
- * Fire-and-forget binds the socket and returns immediately. Sits quietly
412
- * until the native app connects; does nothing visible if it never does.
516
+ * Start the window manager. Automatically detects whether a broker already
517
+ * exists and either becomes the broker or connects as a producer.
413
518
  *
414
- * @param socketPath - Override the configured socket path
519
+ * - If no broker is running: becomes the broker, binds the app socket and
520
+ * a control socket for producers.
521
+ * - If a broker is already running: connects as a producer through the
522
+ * control socket.
523
+ *
524
+ * @param socketPath - Override the configured app socket path
415
525
  * @returns This feature instance for chaining
416
526
  */
417
- listen(socketPath?: string): this {
418
- if (this._server) return this
527
+ async listen(socketPath?: string): Promise<this> {
528
+ if (this._mode) return this
419
529
 
420
530
  socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
531
+ const controlPath = controlPathFor(socketPath)
421
532
 
422
533
  // Ensure the directory exists
423
534
  const dir = dirname(socketPath)
@@ -430,57 +541,80 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
430
541
  }
431
542
  }
432
543
 
433
- // Clean up stale socket file
434
- if (existsSync(socketPath)) {
435
- try {
436
- unlinkSync(socketPath)
437
- } catch (error: any) {
438
- this.setState({ lastError: `Failed to remove stale socket at ${socketPath}: ${error?.message || String(error)}` })
439
- return this
440
- }
544
+ // Try to connect to an existing broker's control socket
545
+ const brokerAlive = await this.probeSocket(controlPath)
546
+ if (brokerAlive) {
547
+ return this.connectAsProducer(controlPath, socketPath)
441
548
  }
442
549
 
443
- const server = new NetServer((socket) => {
444
- this.handleClientConnect(socket)
445
- })
446
-
447
- // Set immediately to prevent parallel calls from creating duplicate servers
448
- this._server = server
449
-
450
- server.on('error', (err) => {
451
- this.setState({ lastError: err.message })
452
- })
453
-
454
- const finalPath = socketPath
455
- server.listen(finalPath, () => {
456
- this.setState({ listening: true, socketPath: finalPath })
457
- this.emit('listening')
458
- })
550
+ // No broker we become the broker
551
+ return this.becomeBroker(socketPath, controlPath)
552
+ }
459
553
 
460
- return this
554
+ /**
555
+ * Remove stale socket files without starting or stopping the server.
556
+ * Useful when a previous process crashed and left dead sockets behind.
557
+ * Will not remove sockets that have live listeners.
558
+ *
559
+ * @param socketPath - Override the configured socket path
560
+ * @returns true if a stale socket was removed
561
+ */
562
+ async cleanupSocket(socketPath?: string): Promise<boolean> {
563
+ socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
564
+ if (this._server) return false
565
+ if (!existsSync(socketPath)) return false
566
+ const isAlive = await this.probeSocket(socketPath)
567
+ if (isAlive) return false
568
+ try {
569
+ unlinkSync(socketPath)
570
+ return true
571
+ } catch {
572
+ return false
573
+ }
461
574
  }
462
575
 
463
576
  /**
464
- * Stop the IPC server and clean up all connections.
577
+ * Stop the window manager and clean up all connections.
465
578
  * Rejects any pending window operation requests.
466
579
  *
467
580
  * @returns This feature instance for chaining
468
581
  */
469
582
  async stop(): Promise<this> {
583
+ // Resolve all pending requests
470
584
  for (const [, pending] of this._pending) {
471
585
  clearTimeout(pending.timer)
472
586
  pending.resolve({ ok: false, error: 'Server stopping' })
473
587
  }
474
588
  this._pending.clear()
475
- this._trackedWindows.clear()
476
589
  this._handles.clear()
477
590
 
591
+ // --- Producer cleanup ---
592
+ if (this._controlClient) {
593
+ this._controlClient.socket.destroy()
594
+ this._controlClient = undefined
595
+ }
596
+
597
+ // --- Broker cleanup ---
478
598
  if (this._client) {
479
599
  this._client.socket.destroy()
480
600
  this._client = undefined
481
601
  }
482
602
 
603
+ for (const [, producer] of this._producers) {
604
+ producer.socket.destroy()
605
+ }
606
+ this._producers.clear()
607
+ this._requestOrigins.clear()
608
+
483
609
  const socketPath = this.state.get('socketPath')
610
+ const controlPath = socketPath ? controlPathFor(socketPath) : undefined
611
+
612
+ if (this._controlServer) {
613
+ await new Promise<void>((resolve) => {
614
+ this._controlServer!.close(() => resolve())
615
+ })
616
+ this._controlServer = undefined
617
+ }
484
618
 
485
619
  if (this._server) {
486
620
  await new Promise<void>((resolve) => {
@@ -489,12 +623,27 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
489
623
  this._server = undefined
490
624
  }
491
625
 
492
- // Clean up the socket file
493
- if (socketPath && existsSync(socketPath)) {
494
- try { unlinkSync(socketPath) } catch { /* ignore */ }
626
+ // Clean up socket files (only if we were the broker)
627
+ if (this._mode === 'broker') {
628
+ if (socketPath && existsSync(socketPath)) {
629
+ try { unlinkSync(socketPath) } catch { /* ignore */ }
630
+ }
631
+ if (controlPath && existsSync(controlPath)) {
632
+ try { unlinkSync(controlPath) } catch { /* ignore */ }
633
+ }
495
634
  }
496
635
 
497
- this.setState({ listening: false, clientConnected: false, socketPath: undefined, windowCount: 0 })
636
+ this._mode = null
637
+ this.setState({
638
+ listening: false,
639
+ clientConnected: false,
640
+ socketPath: undefined,
641
+ windowCount: 0,
642
+ windows: {},
643
+ pendingOperations: [],
644
+ producerCount: 0,
645
+ mode: undefined,
646
+ })
498
647
  return this
499
648
  }
500
649
 
@@ -528,7 +677,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
528
677
  })
529
678
  }
530
679
 
531
- return this.getOrCreateHandle(ackResult.windowId, ackResult)
680
+ return this.getOrCreateHandle(ackResult.windowId, ackResult, 'browser')
532
681
  }
533
682
 
534
683
  /**
@@ -557,7 +706,7 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
557
706
  })
558
707
  }
559
708
 
560
- return this.getOrCreateHandle(ackResult.windowId, ackResult)
709
+ return this.getOrCreateHandle(ackResult.windowId, ackResult, 'terminal')
561
710
  }
562
711
 
563
712
  /**
@@ -712,68 +861,132 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
712
861
  return results
713
862
  }
714
863
 
864
+ /**
865
+ * Write an NDJSON message to the connected app client.
866
+ * In producer mode, routes through the broker.
867
+ * Public so other features can send arbitrary protocol messages over the same socket.
868
+ *
869
+ * @param msg - The message object to send (will be JSON-serialized + newline)
870
+ * @returns True if the message was written, false if no connection is available
871
+ */
872
+ send(msg: Record<string, any>): boolean {
873
+ if (this._mode === 'producer' && this._controlClient) {
874
+ this._controlClient.socket.write(JSON.stringify(msg) + '\n')
875
+ return true
876
+ }
877
+ if (!this._client) return false
878
+ this._client.socket.write(JSON.stringify(msg) + '\n')
879
+ return true
880
+ }
881
+
882
+ // =====================================================================
883
+ // Private — Broker / Producer lifecycle
884
+ // =====================================================================
885
+
715
886
  /** Get or create a tracked WindowHandle for a given windowId. */
716
- private getOrCreateHandle(windowId: string | undefined, result?: WindowAckResult): WindowHandle {
717
- const id = windowId || randomUUID()
718
- let handle = this._handles.get(id)
887
+ private getOrCreateHandle(
888
+ windowId: string | undefined,
889
+ result?: WindowAckResult,
890
+ kind: 'browser' | 'terminal' | 'unknown' = 'unknown',
891
+ ): WindowHandle {
892
+ const id = windowId ?? result?.windowId ?? randomUUID()
893
+ const mapKey = this.handleMapKey(id) ?? id
894
+ let handle = this._handles.get(mapKey)
719
895
  if (!handle) {
720
896
  handle = new WindowHandle(id, this, result)
721
- this._handles.set(id, handle)
897
+ this._handles.set(mapKey, handle)
722
898
  } else if (result) {
723
899
  handle.result = result
724
900
  }
901
+ this.trackWindowOpened(id, { kind: kind !== 'unknown' ? kind : undefined })
725
902
  return handle
726
903
  }
727
904
 
728
- // --- Private internals ---
729
-
730
- private _displayCache: { width: number; height: number } | null = null
731
-
732
905
  /**
733
- * Get the primary display resolution, cached for the lifetime of the feature.
906
+ * Probe an existing socket to see if a live listener is behind it.
907
+ * Attempts a quick connect — if it succeeds, someone is listening.
734
908
  */
735
- private getPrimaryDisplay(): { width: number; height: number } {
736
- if (this._displayCache) return this._displayCache
737
- const osFeature = this.container.feature('os')
738
- const displays = osFeature.getDisplayInfo()
739
- const primary = displays.find(d => d.main) ?? displays[0]
740
- if (!primary) throw new Error('No displays found')
741
- this._displayCache = { width: primary.resolution.width, height: primary.resolution.height }
742
- return this._displayCache
743
- }
909
+ private probeSocket(socketPath: string): Promise<boolean> {
910
+ if (!existsSync(socketPath)) return Promise.resolve(false)
911
+ return new Promise<boolean>((resolve) => {
912
+ const probe = new Socket()
913
+ const timer = setTimeout(() => {
914
+ probe.destroy()
915
+ resolve(false)
916
+ }, 500)
744
917
 
745
- /**
746
- * Resolve percentage-based dimension values to absolute points using the primary display.
747
- * Passes through absolute numbers unchanged. Only fetches display info if percentages are present.
748
- */
749
- private resolveDimensions<T extends Record<string, any>>(opts: T): T {
750
- const keys = ['width', 'height', 'x', 'y'] as const
751
- const hasPercentage = keys.some(k => typeof opts[k] === 'string' && (opts[k] as string).endsWith('%'))
752
- if (!hasPercentage) return opts
918
+ probe.once('connect', () => {
919
+ clearTimeout(timer)
920
+ probe.destroy()
921
+ resolve(true)
922
+ })
753
923
 
754
- const display = this.getPrimaryDisplay()
755
- const resolved = { ...opts }
924
+ probe.once('error', () => {
925
+ clearTimeout(timer)
926
+ probe.destroy()
927
+ resolve(false)
928
+ })
756
929
 
757
- for (const key of keys) {
758
- const val = opts[key]
759
- if (typeof val === 'string' && val.endsWith('%')) {
760
- const pct = parseFloat(val) / 100
761
- const ref = (key === 'width' || key === 'x') ? display.width : display.height
762
- ;(resolved as any)[key] = Math.round(pct * ref)
763
- }
930
+ probe.connect(socketPath)
931
+ })
932
+ }
933
+
934
+ // =====================================================================
935
+ // Broker mode owns the app socket and the control socket
936
+ // =====================================================================
937
+
938
+ private async becomeBroker(socketPath: string, controlPath: string): Promise<this> {
939
+ // Clean up stale app socket
940
+ if (existsSync(socketPath)) {
941
+ try { unlinkSync(socketPath) } catch { /* ignore */ }
942
+ }
943
+ // Clean up stale control socket
944
+ if (existsSync(controlPath)) {
945
+ try { unlinkSync(controlPath) } catch { /* ignore */ }
764
946
  }
765
947
 
766
- return resolved
948
+ // Bind the app-facing socket (native launcher connects here)
949
+ const server = new NetServer((socket) => {
950
+ this.handleAppClientConnect(socket)
951
+ })
952
+ this._server = server
953
+
954
+ server.on('error', (err) => {
955
+ this.setState({ lastError: err.message })
956
+ })
957
+
958
+ await new Promise<void>((resolve) => {
959
+ server.listen(socketPath, () => resolve())
960
+ })
961
+
962
+ // Bind the control socket (producer processes connect here)
963
+ const controlServer = new NetServer((socket) => {
964
+ this.handleProducerConnect(socket)
965
+ })
966
+ this._controlServer = controlServer
967
+
968
+ controlServer.on('error', (err) => {
969
+ this.setState({ lastError: `Control socket error: ${err.message}` })
970
+ })
971
+
972
+ await new Promise<void>((resolve) => {
973
+ controlServer.listen(controlPath, () => resolve())
974
+ })
975
+
976
+ this._mode = 'broker'
977
+ this.setState({ listening: true, socketPath, mode: 'broker' })
978
+ this.emit('listening')
979
+
980
+ return this
767
981
  }
768
982
 
769
983
  /**
770
- * Handle a new client connection from the native app.
984
+ * Handle a new app client connection from the native launcher.
771
985
  * Sets up NDJSON buffering and event forwarding.
772
986
  */
773
- private handleClientConnect(socket: Socket): void {
987
+ private handleAppClientConnect(socket: Socket): void {
774
988
  const client: ClientConnection = { socket, buffer: '' }
775
989
 
776
- // Replace any existing client (single-client model)
777
990
  if (this._client) {
778
991
  this._client.socket.destroy()
779
992
  }
@@ -787,24 +1000,42 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
787
1000
  const lines = client.buffer.split('\n')
788
1001
  client.buffer = lines.pop() || ''
789
1002
  for (const line of lines) {
790
- if (line.trim()) this.processLine(line)
1003
+ if (line.trim()) this.processAppMessage(line)
791
1004
  }
792
1005
  })
793
1006
 
794
1007
  socket.on('close', () => {
795
1008
  if (this._client === client) {
796
1009
  this._client = undefined
797
- this._trackedWindows.clear()
798
1010
  this._handles.clear()
799
- this.setState({ clientConnected: false, windowCount: 0 })
800
-
801
- // Resolve all pending requests — the app is gone, no acks coming
802
- for (const [id, pending] of this._pending) {
1011
+ this.setState({
1012
+ clientConnected: false,
1013
+ windowCount: 0,
1014
+ windows: {},
1015
+ pendingOperations: [],
1016
+ })
1017
+ this.broadcastWindowStateSync()
1018
+
1019
+ // Resolve all pending requests — the app is gone
1020
+ for (const [, pending] of this._pending) {
803
1021
  clearTimeout(pending.timer)
804
1022
  pending.resolve({ ok: false, error: 'Client disconnected' })
805
1023
  }
806
1024
  this._pending.clear()
807
1025
 
1026
+ // Notify producers that app disconnected — resolve their in-flight requests
1027
+ for (const [reqId, producerSocket] of this._requestOrigins) {
1028
+ try {
1029
+ producerSocket.write(JSON.stringify({
1030
+ type: 'windowAck',
1031
+ id: reqId,
1032
+ success: false,
1033
+ error: 'App client disconnected',
1034
+ }) + '\n')
1035
+ } catch { /* producer gone */ }
1036
+ }
1037
+ this._requestOrigins.clear()
1038
+
808
1039
  this.emit('clientDisconnected')
809
1040
  }
810
1041
  })
@@ -815,149 +1046,431 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
815
1046
  }
816
1047
 
817
1048
  /**
818
- * Process a single complete NDJSON line from the app.
819
- * Resolves pending windowAck requests; emits `message` for everything else.
1049
+ * Handle a new producer connection on the control socket.
1050
+ * Producers send window commands; broker forwards to app and routes acks back.
1051
+ */
1052
+ private handleProducerConnect(socket: Socket): void {
1053
+ const id = randomUUID()
1054
+ const client: ClientConnection = { socket, buffer: '' }
1055
+ this._producers.set(id, client)
1056
+ this.syncProducerCount()
1057
+ this.sendWindowStateSyncToSocket(socket)
1058
+
1059
+ socket.on('data', (chunk) => {
1060
+ client.buffer += chunk.toString()
1061
+ const lines = client.buffer.split('\n')
1062
+ client.buffer = lines.pop() || ''
1063
+ for (const line of lines) {
1064
+ if (line.trim()) this.processProducerMessage(line, socket)
1065
+ }
1066
+ })
1067
+
1068
+ socket.on('close', () => {
1069
+ this._producers.delete(id)
1070
+ this.syncProducerCount()
1071
+ // Clean up request origins for this producer
1072
+ for (const [reqId, sock] of this._requestOrigins) {
1073
+ if (sock === socket) this._requestOrigins.delete(reqId)
1074
+ }
1075
+ })
1076
+
1077
+ socket.on('error', () => {
1078
+ this._producers.delete(id)
1079
+ this.syncProducerCount()
1080
+ })
1081
+ }
1082
+
1083
+ /**
1084
+ * Process a message from a producer. Records the origin so acks can be
1085
+ * routed back, then forwards the command to the app client.
820
1086
  */
821
- private processLine(line: string): void {
1087
+ private processProducerMessage(line: string, producerSocket: Socket): void {
822
1088
  let msg: any
823
- try {
824
- msg = JSON.parse(line)
825
- } catch {
826
- return // Malformed JSON ignored per spec
1089
+ try { msg = JSON.parse(line) } catch { return }
1090
+
1091
+ const requestId = this.normalizeRequestId(msg.id)
1092
+ if (requestId) {
1093
+ this._requestOrigins.set(requestId, producerSocket)
827
1094
  }
828
1095
 
829
- // WindowAck from app (response to a window dispatch)
1096
+ // Forward to the native app client
1097
+ if (!this._client) {
1098
+ // No app connected — send error back to producer immediately
1099
+ if (requestId) {
1100
+ this._requestOrigins.delete(requestId)
1101
+ try {
1102
+ producerSocket.write(JSON.stringify({
1103
+ type: 'windowAck',
1104
+ id: msg.id,
1105
+ success: false,
1106
+ error: 'No native launcher client connected',
1107
+ }) + '\n')
1108
+ } catch { /* producer gone */ }
1109
+ }
1110
+ return
1111
+ }
1112
+
1113
+ this._client.socket.write(line + '\n')
1114
+ }
1115
+
1116
+ /**
1117
+ * Process a message from the native app (broker mode).
1118
+ * Routes windowAck back to the originating producer or resolves locally.
1119
+ * Broadcasts lifecycle events to all producers.
1120
+ */
1121
+ private processAppMessage(line: string): void {
1122
+ let msg: any
1123
+ try { msg = JSON.parse(line) } catch { return }
1124
+
830
1125
  if (msg.type === 'windowAck') {
831
1126
  this.emit('windowAck', msg)
832
- this.updateTrackedWindowsFromAck(msg)
1127
+ const rosterChanged = this.updateTrackedWindowsFromAck(msg)
833
1128
 
834
1129
  const ackId = this.normalizeRequestId(msg.id)
835
- if (ackId && this._pending.has(ackId)) {
836
- const pending = this._pending.get(ackId)!
837
- this._pending.delete(ackId)
838
- clearTimeout(pending.timer)
839
-
840
- if (msg.success) {
841
- pending.resolve(msg.result ?? msg)
842
- } else {
843
- pending.resolve({ ok: false, error: msg.error || 'Window operation failed' })
1130
+
1131
+ // Route to originating producer if this was a proxied request
1132
+ if (ackId && this._requestOrigins.has(ackId)) {
1133
+ const producerSocket = this._requestOrigins.get(ackId)!
1134
+ this._requestOrigins.delete(ackId)
1135
+ try {
1136
+ producerSocket.write(line + '\n')
1137
+ } catch { /* producer disconnected */ }
1138
+ if (rosterChanged) this.broadcastWindowStateSync()
1139
+ return
1140
+ }
1141
+
1142
+ // Otherwise resolve locally (broker's own request)
1143
+ if (ackId) {
1144
+ const pending = this.takePendingRequest(ackId)
1145
+ if (pending) {
1146
+ if (msg.success) {
1147
+ pending.resolve(msg.result ?? msg)
1148
+ } else {
1149
+ pending.resolve({ ok: false, error: msg.error || 'Window operation failed' })
1150
+ }
844
1151
  }
845
1152
  }
1153
+ if (rosterChanged) this.broadcastWindowStateSync()
846
1154
  return
847
1155
  }
848
1156
 
1157
+ let messageOut = msg
1158
+
849
1159
  if (msg.type === 'windowClosed') {
850
- this.trackWindowClosed(msg.windowId)
851
- const handle = this._handles.get(msg.windowId)
852
- if (handle) {
853
- handle.emit('close', msg)
854
- this._handles.delete(msg.windowId)
1160
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1161
+ this.trackWindowClosed(messageOut.windowId)
1162
+ const key = this.handleMapKey(messageOut.windowId)
1163
+ const handle = key ? this._handles.get(key) : undefined
1164
+ if (handle && key) {
1165
+ handle.emit('close', messageOut)
1166
+ this._handles.delete(key)
855
1167
  }
856
- this.emit('windowClosed', msg)
1168
+ this.emit('windowClosed', messageOut)
1169
+ // Broadcast to all producers
1170
+ this.broadcastToProducers(JSON.stringify(messageOut))
1171
+ this.broadcastWindowStateSync()
857
1172
  }
858
1173
 
859
1174
  if (msg.type === 'terminalExited') {
860
- const handle = msg.windowId ? this._handles.get(msg.windowId) : undefined
861
- if (handle) handle.emit('terminalExited', msg)
862
- this.emit('terminalExited', msg)
1175
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1176
+ const key = this.handleMapKey(messageOut.windowId)
1177
+ const handle = key ? this._handles.get(key) : undefined
1178
+ if (handle) handle.emit('terminalExited', messageOut)
1179
+ this.emit('terminalExited', messageOut)
1180
+ // Broadcast to all producers
1181
+ this.broadcastToProducers(JSON.stringify(messageOut))
863
1182
  }
864
1183
 
865
- // Everything else is forwarded as a generic message
866
- this.emit('message', msg)
1184
+ if (msg.type === 'windowFocus') {
1185
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1186
+ const key = this.handleMapKey(messageOut.windowId)
1187
+ const handle = key ? this._handles.get(key) : undefined
1188
+ if (handle) handle.emit(messageOut.focused ? 'focus' : 'blur', messageOut)
1189
+ this.emit('windowFocus', messageOut)
1190
+ // Broadcast to all producers
1191
+ this.broadcastToProducers(JSON.stringify(messageOut))
1192
+ }
1193
+
1194
+ this.emit('message', messageOut)
867
1195
  }
868
1196
 
869
- private updateTrackedWindowsFromAck(msg: any): void {
870
- if (!msg?.success) return
871
- if (typeof msg?.action !== 'string') return
1197
+ /** Send an NDJSON line to all connected producers. */
1198
+ private broadcastToProducers(line: string): void {
1199
+ for (const [, producer] of this._producers) {
1200
+ try {
1201
+ producer.socket.write(line + '\n')
1202
+ } catch { /* producer gone */ }
1203
+ }
1204
+ }
872
1205
 
873
- const action = msg.action.toLowerCase()
874
- const resultWindowId = msg?.result?.windowId
1206
+ /** Broker: snapshot of roster + app connectivity for producers (internal protocol). */
1207
+ private buildWindowStateSyncPayload(): Record<string, unknown> {
1208
+ return {
1209
+ type: 'windowStateSync',
1210
+ windows: this.state.get('windows') ?? {},
1211
+ windowCount: this.state.get('windowCount') ?? 0,
1212
+ clientConnected: this.state.get('clientConnected') ?? false,
1213
+ }
1214
+ }
875
1215
 
876
- if (action === 'open' || action === 'spawn' || action === 'terminal') {
877
- this.trackWindowOpened(resultWindowId)
1216
+ /** Broker: push current window roster to one producer control socket. */
1217
+ private sendWindowStateSyncToSocket(socket: Socket): void {
1218
+ if (this._mode !== 'broker') return
1219
+ try {
1220
+ socket.write(JSON.stringify(this.buildWindowStateSyncPayload()) + '\n')
1221
+ } catch { /* gone */ }
1222
+ }
1223
+
1224
+ /** Broker: fan out roster snapshot so all producers converge with broker state. */
1225
+ private broadcastWindowStateSync(): void {
1226
+ if (this._mode !== 'broker' || this._producers.size === 0) return
1227
+ const line = JSON.stringify(this.buildWindowStateSyncPayload())
1228
+ this.broadcastToProducers(line)
1229
+ }
1230
+
1231
+ // =====================================================================
1232
+ // Producer mode — routes commands through the broker
1233
+ // =====================================================================
1234
+
1235
+ private connectAsProducer(controlPath: string, socketPath: string): Promise<this> {
1236
+ return new Promise<this>((resolve, reject) => {
1237
+ const socket = new Socket()
1238
+ const client: ClientConnection = { socket, buffer: '' }
1239
+
1240
+ const timer = setTimeout(() => {
1241
+ socket.destroy()
1242
+ this.setState({ lastError: `Timed out connecting to broker at ${controlPath}` })
1243
+ reject(new WindowManagerError(`Timed out connecting to broker`, 'Timeout'))
1244
+ }, 3000)
1245
+
1246
+ socket.connect(controlPath, () => {
1247
+ clearTimeout(timer)
1248
+ this._mode = 'producer'
1249
+ this._controlClient = client
1250
+ this.setState({ listening: true, socketPath, mode: 'producer' })
1251
+ this.emit('listening')
1252
+ resolve(this)
1253
+ })
1254
+
1255
+ socket.on('data', (chunk) => {
1256
+ client.buffer += chunk.toString()
1257
+ const lines = client.buffer.split('\n')
1258
+ client.buffer = lines.pop() || ''
1259
+ for (const line of lines) {
1260
+ if (line.trim()) this.processProducerIncoming(line)
1261
+ }
1262
+ })
1263
+
1264
+ socket.on('close', () => {
1265
+ this._controlClient = undefined
1266
+ this._mode = null
1267
+ for (const [mapKey, handle] of [...this._handles.entries()]) {
1268
+ handle.emit('close', {
1269
+ type: 'windowClosed',
1270
+ windowId: mapKey,
1271
+ reason: 'brokerDisconnected',
1272
+ })
1273
+ }
1274
+ this._handles.clear()
1275
+ this.setState({
1276
+ listening: false,
1277
+ mode: undefined,
1278
+ pendingOperations: [],
1279
+ windows: {},
1280
+ windowCount: 0,
1281
+ })
1282
+
1283
+ // Resolve all pending requests — broker is gone
1284
+ for (const [, pending] of this._pending) {
1285
+ clearTimeout(pending.timer)
1286
+ pending.resolve({ ok: false, error: 'Broker disconnected' })
1287
+ }
1288
+ this._pending.clear()
1289
+ })
1290
+
1291
+ socket.on('error', (err) => {
1292
+ clearTimeout(timer)
1293
+ this.setState({ lastError: `Broker connection error: ${err.message}` })
1294
+ })
1295
+ })
1296
+ }
1297
+
1298
+ /**
1299
+ * Process a message received from the broker (producer mode).
1300
+ * Handles windowAck (resolves pending requests) and lifecycle events.
1301
+ */
1302
+ private processProducerIncoming(line: string): void {
1303
+ let msg: any
1304
+ try { msg = JSON.parse(line) } catch { return }
1305
+
1306
+ if (msg.type === 'windowStateSync') {
1307
+ this.applyProducerWindowStateSync(msg)
878
1308
  return
879
1309
  }
880
1310
 
881
- if (action === 'close') {
882
- this.trackWindowClosed(resultWindowId)
1311
+ if (msg.type === 'windowAck') {
1312
+ this.emit('windowAck', msg)
1313
+ this.updateTrackedWindowsFromAck(msg)
1314
+
1315
+ const ackId = this.normalizeRequestId(msg.id)
1316
+ if (ackId) {
1317
+ const pending = this.takePendingRequest(ackId)
1318
+ if (pending) {
1319
+ if (msg.success) {
1320
+ pending.resolve(msg.result ?? msg)
1321
+ } else {
1322
+ pending.resolve({ ok: false, error: msg.error || 'Window operation failed' })
1323
+ }
1324
+ }
1325
+ }
1326
+ return
1327
+ }
1328
+
1329
+ let messageOut = msg
1330
+
1331
+ if (msg.type === 'windowClosed') {
1332
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1333
+ this.trackWindowClosed(messageOut.windowId)
1334
+ const key = this.handleMapKey(messageOut.windowId)
1335
+ const handle = key ? this._handles.get(key) : undefined
1336
+ if (handle && key) {
1337
+ handle.emit('close', messageOut)
1338
+ this._handles.delete(key)
1339
+ }
1340
+ this.emit('windowClosed', messageOut)
1341
+ }
1342
+
1343
+ if (msg.type === 'terminalExited') {
1344
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1345
+ const key = this.handleMapKey(messageOut.windowId)
1346
+ const handle = key ? this._handles.get(key) : undefined
1347
+ if (handle) handle.emit('terminalExited', messageOut)
1348
+ this.emit('terminalExited', messageOut)
1349
+ }
1350
+
1351
+ if (msg.type === 'windowFocus') {
1352
+ messageOut = this.enrichLifecycleWithCanonicalWindowId(msg)
1353
+ const key = this.handleMapKey(messageOut.windowId)
1354
+ const handle = key ? this._handles.get(key) : undefined
1355
+ if (handle) handle.emit(messageOut.focused ? 'focus' : 'blur', messageOut)
1356
+ this.emit('windowFocus', messageOut)
883
1357
  }
1358
+
1359
+ this.emit('message', messageOut)
884
1360
  }
885
1361
 
886
- private trackWindowOpened(windowId: unknown): void {
887
- const normalized = this.normalizeRequestId(windowId)
888
- if (!normalized) return
889
- this._trackedWindows.add(normalized)
890
- this.setState({ windowCount: this._trackedWindows.size })
1362
+ // =====================================================================
1363
+ // Shared internals
1364
+ // =====================================================================
1365
+
1366
+ private _displayCache: { width: number; height: number } | null = null
1367
+
1368
+ /**
1369
+ * Get the primary display resolution, cached for the lifetime of the feature.
1370
+ */
1371
+ private getPrimaryDisplay(): { width: number; height: number } {
1372
+ if (this._displayCache) return this._displayCache
1373
+ const osFeature = this.container.feature('os')
1374
+ const displays = osFeature.getDisplayInfo()
1375
+ const primary = displays.find(d => d.main) ?? displays[0]
1376
+ if (!primary) throw new Error('No displays found')
1377
+ this._displayCache = { width: primary.resolution.width, height: primary.resolution.height }
1378
+ return this._displayCache
891
1379
  }
892
1380
 
893
- private trackWindowClosed(windowId: unknown): void {
894
- const normalized = this.normalizeRequestId(windowId)
895
- if (!normalized) return
896
- this._trackedWindows.delete(normalized)
897
- this.setState({ windowCount: this._trackedWindows.size })
1381
+ /**
1382
+ * Resolve percentage-based dimension values to absolute points using the primary display.
1383
+ * Passes through absolute numbers unchanged. Only fetches display info if percentages are present.
1384
+ */
1385
+ private resolveDimensions<T extends Record<string, any>>(opts: T): T {
1386
+ const keys = ['width', 'height', 'x', 'y'] as const
1387
+ const hasPercentage = keys.some(k => typeof opts[k] === 'string' && (opts[k] as string).endsWith('%'))
1388
+ if (!hasPercentage) return opts
1389
+
1390
+ const display = this.getPrimaryDisplay()
1391
+ const resolved = { ...opts }
1392
+
1393
+ for (const key of keys) {
1394
+ const val = opts[key]
1395
+ if (typeof val === 'string' && val.endsWith('%')) {
1396
+ const pct = parseFloat(val) / 100
1397
+ const ref = (key === 'width' || key === 'x') ? display.width : display.height
1398
+ ;(resolved as any)[key] = Math.round(pct * ref)
1399
+ }
1400
+ }
1401
+
1402
+ return resolved
898
1403
  }
899
1404
 
900
1405
  /**
901
- * Send a window dispatch command to the app and wait for the ack.
902
- * Generates a UUID for correlation and sets up a timeout.
1406
+ * Send a window dispatch command and wait for the ack.
1407
+ * In broker mode, sends directly to the app client.
1408
+ * In producer mode, routes through the broker via the control socket.
1409
+ * If not yet started, calls listen() to auto-detect mode.
903
1410
  */
904
1411
  private sendWindowCommand(windowPayload: Record<string, any>): Promise<WindowAckResult> {
905
1412
  return new Promise<WindowAckResult>(async (resolve, reject) => {
906
1413
  const timeoutMs = this.options.requestTimeoutMs || 10000
907
- const bridge = this.getBridgeListener()
908
1414
 
909
- // If the command-listener already owns the socket, bridge through it instead
910
- // of binding a competing server on the same path.
911
- if (!this._server && !bridge?.isListening) {
912
- this.listen()
1415
+ // Auto-start if needed
1416
+ if (!this._mode) {
1417
+ await this.listen()
913
1418
  }
914
1419
 
915
- const connectionMode = await this.waitForAnyClientConnection(timeoutMs, bridge)
916
- if (!connectionMode) {
917
- const error = `No native launcher client connected on socket ${this.state.get('socketPath') || this.options.socketPath}`
918
- this.setState({ lastError: error })
919
- resolve({ ok: false, error, code: 'NoClient' })
920
- return
921
- }
1420
+ // In producer mode, we don't wait for app client — the broker handles that.
1421
+ // We just need our control connection to be alive.
1422
+ if (this._mode === 'producer') {
1423
+ if (!this._controlClient) {
1424
+ resolve({ ok: false, error: 'Not connected to broker', code: 'Disconnected' })
1425
+ return
1426
+ }
922
1427
 
923
- const rawId = randomUUID()
924
- const id = this.normalizeRequestId(rawId) || rawId
925
- const usingBridge = connectionMode === 'bridge'
1428
+ const rawId = randomUUID()
1429
+ const id = this.normalizeRequestId(rawId) || rawId
926
1430
 
927
- let bridgeMessageListener: ((msg: any) => void) | undefined
928
- const cleanupBridgeListener = () => {
929
- if (usingBridge && bridgeMessageListener) {
930
- bridge.off?.('message', bridgeMessageListener)
931
- bridgeMessageListener = undefined
932
- }
933
- }
1431
+ const timer = setTimeout(() => {
1432
+ const p = this.takePendingRequest(id)
1433
+ if (p) {
1434
+ p.resolve({ ok: false, error: `Window ${windowPayload.action} timed out after ${timeoutMs}ms`, code: 'Timeout' })
1435
+ }
1436
+ }, timeoutMs)
1437
+
1438
+ this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
934
1439
 
935
- if (usingBridge) {
936
- bridgeMessageListener = (msg: any) => {
937
- if (!msg || msg.type !== 'windowAck') return
938
- const ackId = this.normalizeRequestId(msg.id)
939
- if (ackId !== id) return
940
- this.processLine(JSON.stringify(msg))
1440
+ const payload = {
1441
+ id,
1442
+ status: 'processing',
1443
+ window: windowPayload,
1444
+ timestamp: new Date().toISOString(),
941
1445
  }
942
- bridge.on?.('message', bridgeMessageListener)
943
- }
944
1446
 
945
- const completeResolve = (value: any) => {
946
- cleanupBridgeListener()
947
- resolve(value)
1447
+ this._controlClient.socket.write(JSON.stringify(payload) + '\n')
1448
+ return
948
1449
  }
949
1450
 
950
- const completeReject = (reason: any) => {
951
- cleanupBridgeListener()
952
- reject(reason)
1451
+ // Broker mode send directly to app client
1452
+ if (!this._client) {
1453
+ // Wait for app client to connect
1454
+ const connected = await this.waitForAppClient(timeoutMs)
1455
+ if (!connected) {
1456
+ const error = `No native launcher client connected on socket ${this.state.get('socketPath') || this.options.socketPath}`
1457
+ this.setState({ lastError: error })
1458
+ resolve({ ok: false, error, code: 'NoClient' })
1459
+ return
1460
+ }
953
1461
  }
954
1462
 
1463
+ const rawId = randomUUID()
1464
+ const id = this.normalizeRequestId(rawId) || rawId
1465
+
955
1466
  const timer = setTimeout(() => {
956
- this._pending.delete(id)
957
- completeResolve({ ok: false, error: `Window ${windowPayload.action} timed out after ${timeoutMs}ms`, code: 'Timeout' })
1467
+ const p = this.takePendingRequest(id)
1468
+ if (p) {
1469
+ p.resolve({ ok: false, error: `Window ${windowPayload.action} timed out after ${timeoutMs}ms`, code: 'Timeout' })
1470
+ }
958
1471
  }, timeoutMs)
959
1472
 
960
- this._pending.set(id, { resolve: completeResolve, reject: completeReject, timer })
1473
+ this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
961
1474
 
962
1475
  const payload = {
963
1476
  id,
@@ -965,30 +1478,125 @@ export class WindowManager extends Feature<WindowManagerState, WindowManagerOpti
965
1478
  window: windowPayload,
966
1479
  timestamp: new Date().toISOString(),
967
1480
  }
968
- const sent = usingBridge ? bridge.send(payload) : this.send(payload)
1481
+ const sent = this.send(payload)
969
1482
 
970
1483
  if (!sent) {
971
- clearTimeout(timer)
972
- this._pending.delete(id)
973
- cleanupBridgeListener()
1484
+ const p = this.takePendingRequest(id)
974
1485
  const error = `Failed to send window ${windowPayload.action}: client disconnected`
975
1486
  this.setState({ lastError: error })
976
- resolve({ ok: false, error, code: 'Disconnected' })
1487
+ if (p) {
1488
+ p.resolve({ ok: false, error, code: 'Disconnected' })
1489
+ }
977
1490
  }
978
1491
  })
979
1492
  }
980
1493
 
981
- /**
982
- * Write an NDJSON message to the connected app client.
983
- * Public so other features can send arbitrary protocol messages over the same socket.
984
- *
985
- * @param msg - The message object to send (will be JSON-serialized + newline)
986
- * @returns True if the message was written, false if no client is connected
987
- */
988
- send(msg: Record<string, any>): boolean {
989
- if (!this._client) return false
990
- this._client.socket.write(JSON.stringify(msg) + '\n')
991
- return true
1494
+ /** Wait for the native app to connect (broker mode only). */
1495
+ private waitForAppClient(timeoutMs: number): Promise<boolean> {
1496
+ if (this._client) return Promise.resolve(true)
1497
+
1498
+ return new Promise<boolean>((resolve) => {
1499
+ const timer = setTimeout(() => {
1500
+ this.off('clientConnected', onConnected)
1501
+ resolve(false)
1502
+ }, timeoutMs)
1503
+
1504
+ const onConnected = () => {
1505
+ clearTimeout(timer)
1506
+ resolve(true)
1507
+ }
1508
+
1509
+ this.once('clientConnected', onConnected)
1510
+ })
1511
+ }
1512
+
1513
+ /** Returns true if the open-window roster changed (broker should fan out `windowStateSync`). */
1514
+ private updateTrackedWindowsFromAck(msg: any): boolean {
1515
+ if (!msg?.success) return false
1516
+ if (typeof msg?.action !== 'string') return false
1517
+
1518
+ const action = msg.action.toLowerCase()
1519
+ const resultWindowId = msg?.result?.windowId
1520
+
1521
+ if (action === 'open' || action === 'spawn' || action === 'terminal') {
1522
+ const before = Object.keys(this.state.get('windows') ?? {}).length
1523
+ this.trackWindowOpened(resultWindowId, {
1524
+ kind: action === 'terminal' ? 'terminal' : 'browser',
1525
+ lastAck: msg,
1526
+ })
1527
+ return Object.keys(this.state.get('windows') ?? {}).length !== before
1528
+ }
1529
+
1530
+ if (action === 'close') {
1531
+ const before = Object.keys(this.state.get('windows') ?? {}).length
1532
+ this.trackWindowClosed(resultWindowId)
1533
+ return Object.keys(this.state.get('windows') ?? {}).length !== before
1534
+ }
1535
+
1536
+ return false
1537
+ }
1538
+
1539
+ /** Producer: apply broker snapshot; drop local handles for windows no longer in the roster. */
1540
+ private applyProducerWindowStateSync(msg: any): void {
1541
+ const raw = msg?.windows
1542
+ const windows =
1543
+ raw && typeof raw === 'object' && !Array.isArray(raw)
1544
+ ? (raw as Record<string, WindowTrackedEntry>)
1545
+ : {}
1546
+ const windowCount =
1547
+ typeof msg?.windowCount === 'number' ? msg.windowCount : Object.keys(windows).length
1548
+ const patch: Partial<WindowManagerState> = { windows, windowCount }
1549
+ if (typeof msg?.clientConnected === 'boolean') {
1550
+ patch.clientConnected = msg.clientConnected
1551
+ }
1552
+ this.setState(patch)
1553
+ this.pruneHandlesMissingFromRoster(windows)
1554
+ }
1555
+
1556
+ private pruneHandlesMissingFromRoster(windows: Record<string, WindowTrackedEntry>): void {
1557
+ const allowed = new Set(Object.keys(windows))
1558
+ for (const [mapKey, handle] of [...this._handles.entries()]) {
1559
+ if (!allowed.has(mapKey)) {
1560
+ handle.emit('close', {
1561
+ type: 'windowClosed',
1562
+ windowId: mapKey,
1563
+ reason: 'windowStateSync',
1564
+ })
1565
+ this._handles.delete(mapKey)
1566
+ }
1567
+ }
1568
+ }
1569
+
1570
+ private trackWindowOpened(
1571
+ windowId: unknown,
1572
+ extra?: { kind?: 'browser' | 'terminal' | 'unknown'; lastAck?: unknown },
1573
+ ): void {
1574
+ const normalized = this.normalizeRequestId(windowId)
1575
+ if (!normalized) return
1576
+ const native = typeof windowId === 'string' ? windowId : normalized
1577
+ this.setState((cur) => {
1578
+ const windows = { ...(cur.windows ?? {}) }
1579
+ const prev = windows[normalized]
1580
+ windows[normalized] = {
1581
+ windowId: normalized,
1582
+ nativeWindowId: prev?.nativeWindowId ?? native,
1583
+ openedAt: prev?.openedAt ?? Date.now(),
1584
+ lastAck:
1585
+ extra?.lastAck !== undefined ? this.snapshotForState(extra.lastAck) : prev?.lastAck,
1586
+ kind: extra?.kind ?? prev?.kind ?? 'unknown',
1587
+ }
1588
+ return { windows, windowCount: Object.keys(windows).length }
1589
+ })
1590
+ }
1591
+
1592
+ private trackWindowClosed(windowId: unknown): void {
1593
+ const normalized = this.normalizeRequestId(windowId)
1594
+ if (!normalized) return
1595
+ this.setState((cur) => {
1596
+ const windows = { ...(cur.windows ?? {}) }
1597
+ delete windows[normalized]
1598
+ return { windows, windowCount: Object.keys(windows).length }
1599
+ })
992
1600
  }
993
1601
  }
994
1602