@soederpop/luca 0.0.28 → 0.0.30

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.
Files changed (51) hide show
  1. package/commands/try-all-challenges.ts +1 -1
  2. package/docs/TABLE-OF-CONTENTS.md +0 -3
  3. package/docs/examples/structured-output-with-assistants.md +144 -0
  4. package/docs/tutorials/20-browser-esm.md +234 -0
  5. package/package.json +1 -1
  6. package/src/agi/container.server.ts +4 -0
  7. package/src/agi/features/assistant.ts +132 -2
  8. package/src/agi/features/browser-use.ts +623 -0
  9. package/src/agi/features/conversation.ts +135 -45
  10. package/src/agi/lib/interceptor-chain.ts +79 -0
  11. package/src/bootstrap/generated.ts +381 -308
  12. package/src/cli/build-info.ts +2 -2
  13. package/src/clients/rest.ts +7 -7
  14. package/src/commands/chat.ts +22 -0
  15. package/src/commands/describe.ts +67 -2
  16. package/src/commands/prompt.ts +23 -3
  17. package/src/container.ts +411 -113
  18. package/src/helper.ts +189 -5
  19. package/src/introspection/generated.agi.ts +17664 -11568
  20. package/src/introspection/generated.node.ts +4891 -1860
  21. package/src/introspection/generated.web.ts +379 -291
  22. package/src/introspection/index.ts +7 -0
  23. package/src/introspection/scan.ts +224 -7
  24. package/src/node/container.ts +31 -10
  25. package/src/node/features/content-db.ts +7 -7
  26. package/src/node/features/disk-cache.ts +11 -11
  27. package/src/node/features/esbuild.ts +3 -3
  28. package/src/node/features/file-manager.ts +37 -16
  29. package/src/node/features/fs.ts +64 -25
  30. package/src/node/features/git.ts +10 -10
  31. package/src/node/features/helpers.ts +25 -18
  32. package/src/node/features/ink.ts +13 -13
  33. package/src/node/features/ipc-socket.ts +8 -8
  34. package/src/node/features/networking.ts +3 -3
  35. package/src/node/features/os.ts +7 -7
  36. package/src/node/features/package-finder.ts +15 -15
  37. package/src/node/features/proc.ts +1 -1
  38. package/src/node/features/ui.ts +13 -13
  39. package/src/node/features/vm.ts +4 -4
  40. package/src/scaffolds/generated.ts +1 -1
  41. package/src/servers/express.ts +6 -6
  42. package/src/servers/mcp.ts +4 -4
  43. package/src/servers/socket.ts +6 -6
  44. package/test/interceptor-chain.test.ts +61 -0
  45. package/docs/apis/features/node/window-manager.md +0 -445
  46. package/docs/examples/window-manager-layouts.md +0 -180
  47. package/docs/examples/window-manager.md +0 -125
  48. package/docs/window-manager-fix.md +0 -249
  49. package/scripts/test-window-manager-lifecycle.ts +0 -86
  50. package/scripts/test-window-manager.ts +0 -43
  51. package/src/node/features/window-manager.ts +0 -1603
@@ -1,1603 +0,0 @@
1
- import { z } from 'zod'
2
- import { FeatureStateSchema, FeatureOptionsSchema, FeatureEventsSchema } from '../../schemas/base.js'
3
- import { Feature } from '../feature.js'
4
- import { Bus } from '../../bus.js'
5
- import { Server as NetServer, Socket } from 'net'
6
- import { homedir } from 'os'
7
- import { join, dirname } from 'path'
8
- import { randomUUID } from 'crypto'
9
- import { existsSync, unlinkSync, mkdirSync } from 'fs'
10
-
11
- const DEFAULT_SOCKET_PATH = join(
12
- homedir(),
13
- 'Library',
14
- 'Application Support',
15
- 'LucaVoiceLauncher',
16
- 'ipc-window.sock'
17
- )
18
-
19
- function controlPathFor(socketPath: string): string {
20
- return socketPath.replace(/\.sock$/, '-control.sock')
21
- }
22
-
23
- const ErrorCodes = ['BadRequest', 'NotFound', 'EvalFailed', 'Internal', 'Timeout', 'Disconnected', 'NoClient'] as const
24
- type WindowManagerErrorCode = typeof ErrorCodes[number]
25
-
26
- /**
27
- * Custom error class for WindowManager operations.
28
- * Carries a `code` property for programmatic error handling.
29
- */
30
- export class WindowManagerError extends Error {
31
- code: WindowManagerErrorCode
32
-
33
- constructor(message: string, code: WindowManagerErrorCode = 'Internal') {
34
- super(message)
35
- this.name = 'WindowManagerError'
36
- this.code = code
37
- }
38
- }
39
-
40
- // --- Schemas ---
41
-
42
- export const WindowManagerOptionsSchema = FeatureOptionsSchema.extend({
43
- socketPath: z.string().default(DEFAULT_SOCKET_PATH)
44
- .describe('Path to the Unix domain socket the server listens on'),
45
- autoListen: z.boolean().optional()
46
- .describe('Automatically start listening when the feature is enabled'),
47
- requestTimeoutMs: z.number().default(10000)
48
- .describe('Per-request timeout in milliseconds for window operations'),
49
- })
50
- export type WindowManagerOptions = z.infer<typeof WindowManagerOptionsSchema>
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
-
70
- export const WindowManagerStateSchema = FeatureStateSchema.extend({
71
- listening: z.boolean().default(false)
72
- .describe('Whether the IPC server is listening'),
73
- clientConnected: z.boolean().default(false)
74
- .describe('Whether the native launcher app is connected'),
75
- socketPath: z.string().optional()
76
- .describe('The socket path in use'),
77
- windowCount: z.number().default(0)
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)'),
85
- lastError: z.string().optional()
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)'),
89
- })
90
- export type WindowManagerState = z.infer<typeof WindowManagerStateSchema>
91
-
92
- export const WindowManagerEventsSchema = FeatureEventsSchema.extend({
93
- listening: z.tuple([]).describe('Emitted when the IPC server starts listening'),
94
- clientConnected: z.tuple([z.any().describe('The client socket')]).describe('Emitted when the native app connects'),
95
- clientDisconnected: z.tuple([]).describe('Emitted when the native app disconnects'),
96
- message: z.tuple([z.any().describe('The parsed message object')]).describe('Emitted for any incoming message that is not a windowAck'),
97
- windowAck: z.tuple([z.any().describe('The window ack payload')]).describe('Emitted when a window ack is received from the app'),
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'),
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'),
101
- error: z.tuple([z.any().describe('The error')]).describe('Emitted on error'),
102
- })
103
-
104
- // --- Types ---
105
-
106
- /** A dimension value — either absolute points or a percentage string like `"50%"`. */
107
- export type DimensionValue = number | `${number}%`
108
-
109
- /**
110
- * Options for spawning a new native browser window.
111
- * Dimensions and positions accept absolute points or percentage strings (e.g. `"50%"`)
112
- * resolved against the primary display.
113
- */
114
- export interface SpawnOptions {
115
- url?: string
116
- width?: DimensionValue
117
- height?: DimensionValue
118
- x?: DimensionValue
119
- y?: DimensionValue
120
- alwaysOnTop?: boolean
121
- window?: {
122
- decorations?: 'normal' | 'hiddenTitleBar' | 'none'
123
- transparent?: boolean
124
- shadow?: boolean
125
- alwaysOnTop?: boolean
126
- opacity?: number
127
- clickThrough?: boolean
128
- }
129
- }
130
-
131
- /**
132
- * Options for spawning a native terminal window.
133
- * Dimensions and positions accept absolute points or percentage strings (e.g. `"50%"`)
134
- * resolved against the primary display.
135
- */
136
- export interface SpawnTTYOptions {
137
- /** Executable name or path (required). */
138
- command: string
139
- /** Arguments passed after the command. */
140
- args?: string[]
141
- /** Working directory for the process. */
142
- cwd?: string
143
- /** Environment variable overrides. */
144
- env?: Record<string, string>
145
- /** Initial terminal columns. */
146
- cols?: number
147
- /** Initial terminal rows. */
148
- rows?: number
149
- /** Window title. */
150
- title?: string
151
- /** Window width in points. */
152
- width?: DimensionValue
153
- /** Window height in points. */
154
- height?: DimensionValue
155
- /** Window x position. */
156
- x?: DimensionValue
157
- /** Window y position. */
158
- y?: DimensionValue
159
- /** Chrome options (decorations, alwaysOnTop, etc.) */
160
- window?: SpawnOptions['window']
161
- }
162
-
163
- /**
164
- * Options for capturing a screenshot from a native window.
165
- */
166
- export interface WindowScreenGrabOptions {
167
- /** Window ID. If omitted, the launcher uses the most recent window. */
168
- windowId?: string
169
- /** Output file path for the PNG image. */
170
- path: string
171
- }
172
-
173
- /**
174
- * Options for recording video from a native window.
175
- */
176
- export interface WindowVideoOptions {
177
- /** Window ID. If omitted, the launcher uses the most recent window. */
178
- windowId?: string
179
- /** Output file path for the video file. */
180
- path: string
181
- /** Recording duration in milliseconds. */
182
- durationMs?: number
183
- }
184
-
185
- /**
186
- * The result returned from a window ack.
187
- */
188
- export interface WindowAckResult {
189
- ok?: boolean
190
- windowId?: string
191
- value?: string
192
- json?: any
193
- [key: string]: any
194
- }
195
-
196
- // --- Layout Types ---
197
-
198
- /**
199
- * A single entry in a layout configuration.
200
- * Use `type: 'tty'` for terminal windows, `type: 'window'` (or omit) for browser windows.
201
- * If `type` is omitted, entries with a `command` field are treated as TTY, otherwise as window.
202
- */
203
- export type LayoutEntry =
204
- | ({ type: 'window' } & SpawnOptions)
205
- | ({ type: 'tty' } & SpawnTTYOptions)
206
- | ({ type?: undefined } & SpawnOptions)
207
- | ({ type?: undefined; command: string } & SpawnTTYOptions)
208
-
209
- // --- WindowHandle Events ---
210
-
211
- interface WindowHandleEvents {
212
- close: [msg: any]
213
- terminalExited: [msg: any]
214
- focus: [msg: any]
215
- blur: [msg: any]
216
- }
217
-
218
- // --- WindowHandle ---
219
-
220
- /**
221
- * A lightweight handle to a single native window.
222
- * Delegates all operations back to the WindowManager instance.
223
- * Emits lifecycle events (`close`, `terminalExited`) when the native app reports them.
224
- *
225
- * @example
226
- * ```typescript
227
- * const handle = await windowManager.spawn({ url: 'https://example.com' })
228
- * handle.on('close', (msg) => console.log('window closed', msg))
229
- * handle.on('terminalExited', (info) => console.log('process exited', info))
230
- * ```
231
- */
232
- export class WindowHandle {
233
- private _events = new Bus<WindowHandleEvents>()
234
-
235
- /** The original ack result from spawning this window. */
236
- public result: WindowAckResult
237
-
238
- constructor(
239
- public readonly windowId: string,
240
- private manager: WindowManager,
241
- result?: WindowAckResult
242
- ) {
243
- this.result = result ?? {}
244
- }
245
-
246
- /** Register a listener for a lifecycle event. */
247
- on<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
248
- this._events.on(event, listener)
249
- return this
250
- }
251
-
252
- /** Remove a listener for a lifecycle event. */
253
- off<E extends keyof WindowHandleEvents>(event: E, listener?: (...args: WindowHandleEvents[E]) => void): this {
254
- this._events.off(event, listener)
255
- return this
256
- }
257
-
258
- async waitFor(event: string) {
259
- return this._events.waitFor(event as any)
260
- }
261
-
262
- /** Register a one-time listener for a lifecycle event. */
263
- once<E extends keyof WindowHandleEvents>(event: E, listener: (...args: WindowHandleEvents[E]) => void): this {
264
- this._events.once(event, listener)
265
- return this
266
- }
267
-
268
- /** Emit a lifecycle event on this handle. */
269
- emit<E extends keyof WindowHandleEvents>(event: E, ...args: WindowHandleEvents[E]): void {
270
- this._events.emit(event, ...args)
271
- }
272
-
273
- /** Bring this window to the front. */
274
- async focus(): Promise<WindowAckResult> {
275
- return this.manager.focus(this.windowId)
276
- }
277
-
278
- /** Close this window. */
279
- async close(): Promise<WindowAckResult> {
280
- return this.manager.close(this.windowId)
281
- }
282
-
283
- /** Navigate this window to a URL. */
284
- async navigate(url: string): Promise<WindowAckResult> {
285
- return this.manager.navigate(this.windowId, url)
286
- }
287
-
288
- /** Evaluate JavaScript in this window's web view. */
289
- async eval(code: string, opts?: { timeoutMs?: number; returnJson?: boolean }): Promise<WindowAckResult> {
290
- return this.manager.eval(this.windowId, code, opts)
291
- }
292
-
293
- /** Capture a PNG screenshot of this window. */
294
- async screengrab(path: string): Promise<WindowAckResult> {
295
- return this.manager.screengrab({ windowId: this.windowId, path })
296
- }
297
-
298
- /** Record a video of this window to disk. */
299
- async video(path: string, opts?: { durationMs?: number }): Promise<WindowAckResult> {
300
- return this.manager.video({ windowId: this.windowId, path, durationMs: opts?.durationMs })
301
- }
302
- }
303
-
304
- // --- Private types ---
305
-
306
- type PendingRequest = {
307
- resolve: (value: any) => void
308
- reject: (reason: any) => void
309
- timer: ReturnType<typeof setTimeout>
310
- }
311
-
312
- interface ClientConnection {
313
- socket: Socket
314
- buffer: string
315
- }
316
-
317
- // --- Feature ---
318
-
319
- /**
320
- * WindowManager Feature — Native window control via LucaVoiceLauncher
321
- *
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.
333
- *
334
- * **Protocol:**
335
- * - Bun listens on a Unix domain socket; the native app connects as a client
336
- * - Window dispatch commands are sent as NDJSON with a `window` field
337
- * - The app executes window commands and sends back `windowAck` messages
338
- * - Any non-windowAck message from the app is emitted as a `message` event
339
- * - Other features can use `send()` to write arbitrary NDJSON to the app
340
- *
341
- * **Capabilities:**
342
- * - Spawn native browser windows with configurable chrome
343
- * - Navigate, focus, close, and eval JavaScript in windows
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).
354
- *
355
- * @example
356
- * ```typescript
357
- * const wm = container.feature('windowManager', { enable: true, autoListen: true })
358
- *
359
- * const handle = await wm.spawn({ url: 'https://google.com', width: 800, height: 600 })
360
- * handle.on('close', (msg) => console.log('window closed'))
361
- * await handle.navigate('https://news.ycombinator.com')
362
- * const title = await handle.eval('document.title')
363
- * await handle.close()
364
- *
365
- * // Other features can listen for non-window messages
366
- * wm.on('message', (msg) => console.log('App says:', msg))
367
- *
368
- * // Other features can write raw NDJSON to the app
369
- * wm.send({ id: 'abc', status: 'processing', speech: 'Working on it' })
370
- * ```
371
- */
372
- export class WindowManager extends Feature<WindowManagerState, WindowManagerOptions> {
373
- static override shortcut = 'features.windowManager' as const
374
- static override stateSchema = WindowManagerStateSchema
375
- static override optionsSchema = WindowManagerOptionsSchema
376
- static override eventsSchema = WindowManagerEventsSchema
377
- static { Feature.register(this, 'windowManager') }
378
-
379
- // --- Shared state ---
380
- private _pending = new Map<string, PendingRequest>()
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
393
-
394
- private normalizeRequestId(value: unknown): string | undefined {
395
- if (typeof value !== 'string') return undefined
396
- return value.toLowerCase()
397
- }
398
-
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
- }
404
-
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
- }
426
-
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
- }
435
-
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
- }
445
-
446
- private syncProducerCount(): void {
447
- this.setState({ producerCount: this._producers.size })
448
- }
449
-
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
- }))
458
- }
459
-
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
470
- }
471
-
472
- /** Default state: not listening, no client connected, zero windows tracked. */
473
- override get initialState(): WindowManagerState {
474
- return {
475
- ...super.initialState,
476
- listening: false,
477
- clientConnected: false,
478
- windowCount: 0,
479
- windows: {},
480
- pendingOperations: [],
481
- producerCount: 0,
482
- }
483
- }
484
-
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). */
496
- get isListening(): boolean {
497
- return this.state.get('listening') || false
498
- }
499
-
500
- /** Whether the native app client is currently connected (only meaningful for broker). */
501
- get isClientConnected(): boolean {
502
- return this.state.get('clientConnected') || false
503
- }
504
-
505
- override async enable(options: any = {}): Promise<this> {
506
- await super.enable(options)
507
-
508
- if (this.options.autoListen) {
509
- await this.listen()
510
- }
511
-
512
- return this
513
- }
514
-
515
- /**
516
- * Start the window manager. Automatically detects whether a broker already
517
- * exists and either becomes the broker or connects as a producer.
518
- *
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
525
- * @returns This feature instance for chaining
526
- */
527
- async listen(socketPath?: string): Promise<this> {
528
- if (this._mode) return this
529
-
530
- socketPath = socketPath || this.options.socketPath || DEFAULT_SOCKET_PATH
531
- const controlPath = controlPathFor(socketPath)
532
-
533
- // Ensure the directory exists
534
- const dir = dirname(socketPath)
535
- if (!existsSync(dir)) {
536
- try {
537
- mkdirSync(dir, { recursive: true })
538
- } catch (error: any) {
539
- this.setState({ lastError: `Failed to create socket directory ${dir}: ${error?.message || String(error)}` })
540
- return this
541
- }
542
- }
543
-
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)
548
- }
549
-
550
- // No broker — we become the broker
551
- return this.becomeBroker(socketPath, controlPath)
552
- }
553
-
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
- }
574
- }
575
-
576
- /**
577
- * Stop the window manager and clean up all connections.
578
- * Rejects any pending window operation requests.
579
- *
580
- * @returns This feature instance for chaining
581
- */
582
- async stop(): Promise<this> {
583
- // Resolve all pending requests
584
- for (const [, pending] of this._pending) {
585
- clearTimeout(pending.timer)
586
- pending.resolve({ ok: false, error: 'Server stopping' })
587
- }
588
- this._pending.clear()
589
- this._handles.clear()
590
-
591
- // --- Producer cleanup ---
592
- if (this._controlClient) {
593
- this._controlClient.socket.destroy()
594
- this._controlClient = undefined
595
- }
596
-
597
- // --- Broker cleanup ---
598
- if (this._client) {
599
- this._client.socket.destroy()
600
- this._client = undefined
601
- }
602
-
603
- for (const [, producer] of this._producers) {
604
- producer.socket.destroy()
605
- }
606
- this._producers.clear()
607
- this._requestOrigins.clear()
608
-
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
- }
618
-
619
- if (this._server) {
620
- await new Promise<void>((resolve) => {
621
- this._server!.close(() => resolve())
622
- })
623
- this._server = undefined
624
- }
625
-
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
- }
634
- }
635
-
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
- })
647
- return this
648
- }
649
-
650
- // --- Window Operations ---
651
-
652
- /**
653
- * Spawn a new native browser window.
654
- * Sends a window dispatch to the app and waits for the ack.
655
- *
656
- * @param opts - Window configuration (url, dimensions, chrome options)
657
- * @returns A WindowHandle for the spawned window (with `.result` containing the ack data)
658
- */
659
- async spawn(opts: SpawnOptions = {}): Promise<WindowHandle> {
660
- const resolved = this.resolveDimensions(opts)
661
- const { window: windowChrome, ...flat } = resolved
662
-
663
- let ackResult: WindowAckResult
664
- if (windowChrome) {
665
- ackResult = await this.sendWindowCommand({
666
- action: 'open',
667
- request: {
668
- ...flat,
669
- window: windowChrome,
670
- },
671
- })
672
- } else {
673
- ackResult = await this.sendWindowCommand({
674
- action: 'open',
675
- ...flat,
676
- alwaysOnTop: flat.alwaysOnTop ?? false,
677
- })
678
- }
679
-
680
- return this.getOrCreateHandle(ackResult.windowId, ackResult, 'browser')
681
- }
682
-
683
- /**
684
- * Spawn a native terminal window running a command.
685
- * The terminal is read-only — stdout/stderr are rendered with ANSI support.
686
- * Closing the window terminates the process.
687
- *
688
- * @param opts - Terminal configuration (command, args, cwd, dimensions, etc.)
689
- * @returns A WindowHandle for the spawned terminal (with `.result` containing the ack data)
690
- */
691
- async spawnTTY(opts: SpawnTTYOptions): Promise<WindowHandle> {
692
- const resolved = this.resolveDimensions(opts)
693
- const { window: windowChrome, ...flat } = resolved
694
-
695
- let ackResult: WindowAckResult
696
- if (windowChrome) {
697
- ackResult = await this.sendWindowCommand({
698
- action: 'terminal',
699
- ...flat,
700
- window: windowChrome,
701
- })
702
- } else {
703
- ackResult = await this.sendWindowCommand({
704
- action: 'terminal',
705
- ...flat,
706
- })
707
- }
708
-
709
- return this.getOrCreateHandle(ackResult.windowId, ackResult, 'terminal')
710
- }
711
-
712
- /**
713
- * Bring a window to the front.
714
- *
715
- * @param windowId - The window ID. If omitted, the app uses the most recent window.
716
- * @returns The window ack result
717
- */
718
- async focus(windowId?: string): Promise<WindowAckResult> {
719
- return this.sendWindowCommand({
720
- action: 'focus',
721
- ...(windowId ? { windowId } : {}),
722
- })
723
- }
724
-
725
- /**
726
- * Close a window.
727
- *
728
- * @param windowId - The window ID. If omitted, the app closes the most recent window.
729
- * @returns The window ack result
730
- */
731
- async close(windowId?: string): Promise<WindowAckResult> {
732
- return this.sendWindowCommand({
733
- action: 'close',
734
- ...(windowId ? { windowId } : {}),
735
- })
736
- }
737
-
738
- /**
739
- * Navigate a window to a new URL.
740
- *
741
- * @param windowId - The window ID
742
- * @param url - The URL to navigate to
743
- * @returns The window ack result
744
- */
745
- async navigate(windowId: string, url: string): Promise<WindowAckResult> {
746
- return this.sendWindowCommand({
747
- action: 'navigate',
748
- windowId,
749
- url,
750
- })
751
- }
752
-
753
- /**
754
- * Evaluate JavaScript in a window's web view.
755
- *
756
- * @param windowId - The window ID
757
- * @param code - JavaScript code to evaluate
758
- * @param opts - timeoutMs (default 5000), returnJson (default true)
759
- * @returns The evaluation result from the window ack
760
- */
761
- async eval(windowId: string, code: string, opts?: { timeoutMs?: number; returnJson?: boolean }): Promise<WindowAckResult> {
762
- return this.sendWindowCommand({
763
- action: 'eval',
764
- windowId,
765
- code,
766
- ...(opts?.timeoutMs != null ? { timeoutMs: opts.timeoutMs } : {}),
767
- ...(opts?.returnJson != null ? { returnJson: opts.returnJson } : {}),
768
- })
769
- }
770
-
771
- /**
772
- * Capture a PNG screenshot from a window.
773
- *
774
- * @param opts - Window target and output path
775
- * @returns The window ack result
776
- */
777
- async screengrab(opts: WindowScreenGrabOptions): Promise<WindowAckResult> {
778
- return this.sendWindowCommand({
779
- action: 'screengrab',
780
- ...(opts.windowId ? { windowId: opts.windowId } : {}),
781
- path: opts.path,
782
- })
783
- }
784
-
785
- /**
786
- * Record a video from a window to disk.
787
- *
788
- * @param opts - Window target, output path, and optional duration
789
- * @returns The window ack result
790
- */
791
- async video(opts: WindowVideoOptions): Promise<WindowAckResult> {
792
- return this.sendWindowCommand({
793
- action: 'video',
794
- ...(opts.windowId ? { windowId: opts.windowId } : {}),
795
- path: opts.path,
796
- ...(opts.durationMs != null ? { durationMs: opts.durationMs } : {}),
797
- })
798
- }
799
-
800
- /**
801
- * Get a WindowHandle for chainable operations on a specific window.
802
- * Returns the tracked handle if one exists, otherwise creates a new one.
803
- *
804
- * @param windowId - The window ID
805
- * @returns A WindowHandle instance
806
- */
807
- window(windowId: string): WindowHandle {
808
- return this.getOrCreateHandle(windowId)
809
- }
810
-
811
- /**
812
- * Spawn multiple windows in parallel from a layout configuration.
813
- * Returns handles in the same order as the config entries.
814
- *
815
- * @param config - Array of layout entries (window or tty)
816
- * @returns Array of WindowHandle instances
817
- *
818
- * @example
819
- * ```typescript
820
- * const handles = await wm.spawnLayout([
821
- * { type: 'window', url: 'https://google.com', width: 800, height: 600 },
822
- * { type: 'tty', command: 'htop' },
823
- * { url: 'https://github.com' }, // defaults to window
824
- * ])
825
- * ```
826
- */
827
- async spawnLayout(config: LayoutEntry[]): Promise<WindowHandle[]> {
828
- const handles: WindowHandle[] = []
829
- for (const entry of config) {
830
- if (entry.type === 'tty' || ('command' in entry && !entry.type)) {
831
- const { type, ...opts } = entry as { type?: string } & SpawnTTYOptions
832
- handles.push(await this.spawnTTY(opts))
833
- } else {
834
- const { type, ...opts } = entry as { type?: string } & SpawnOptions
835
- handles.push(await this.spawn(opts))
836
- }
837
- }
838
- return handles
839
- }
840
-
841
- /**
842
- * Spawn multiple layouts sequentially. Each layout's windows spawn in parallel,
843
- * but the next layout waits for the previous one to fully complete.
844
- *
845
- * @param configs - Array of layout configurations
846
- * @returns Array of handle arrays, one per layout
847
- *
848
- * @example
849
- * ```typescript
850
- * const [firstBatch, secondBatch] = await wm.spawnLayouts([
851
- * [{ url: 'https://google.com' }, { url: 'https://github.com' }],
852
- * [{ type: 'tty', command: 'htop' }],
853
- * ])
854
- * ```
855
- */
856
- async spawnLayouts(configs: LayoutEntry[][]): Promise<WindowHandle[][]> {
857
- const results: WindowHandle[][] = []
858
- for (const config of configs) {
859
- results.push(await this.spawnLayout(config))
860
- }
861
- return results
862
- }
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
-
886
- /** Get or create a tracked WindowHandle for a given windowId. */
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)
895
- if (!handle) {
896
- handle = new WindowHandle(id, this, result)
897
- this._handles.set(mapKey, handle)
898
- } else if (result) {
899
- handle.result = result
900
- }
901
- this.trackWindowOpened(id, { kind: kind !== 'unknown' ? kind : undefined })
902
- return handle
903
- }
904
-
905
- /**
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.
908
- */
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)
917
-
918
- probe.once('connect', () => {
919
- clearTimeout(timer)
920
- probe.destroy()
921
- resolve(true)
922
- })
923
-
924
- probe.once('error', () => {
925
- clearTimeout(timer)
926
- probe.destroy()
927
- resolve(false)
928
- })
929
-
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 */ }
946
- }
947
-
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
981
- }
982
-
983
- /**
984
- * Handle a new app client connection from the native launcher.
985
- * Sets up NDJSON buffering and event forwarding.
986
- */
987
- private handleAppClientConnect(socket: Socket): void {
988
- const client: ClientConnection = { socket, buffer: '' }
989
-
990
- if (this._client) {
991
- this._client.socket.destroy()
992
- }
993
- this._client = client
994
-
995
- this.setState({ clientConnected: true })
996
- this.emit('clientConnected', socket)
997
-
998
- socket.on('data', (chunk) => {
999
- client.buffer += chunk.toString()
1000
- const lines = client.buffer.split('\n')
1001
- client.buffer = lines.pop() || ''
1002
- for (const line of lines) {
1003
- if (line.trim()) this.processAppMessage(line)
1004
- }
1005
- })
1006
-
1007
- socket.on('close', () => {
1008
- if (this._client === client) {
1009
- this._client = undefined
1010
- this._handles.clear()
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) {
1021
- clearTimeout(pending.timer)
1022
- pending.resolve({ ok: false, error: 'Client disconnected' })
1023
- }
1024
- this._pending.clear()
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
-
1039
- this.emit('clientDisconnected')
1040
- }
1041
- })
1042
-
1043
- socket.on('error', (err) => {
1044
- this.setState({ lastError: err.message })
1045
- })
1046
- }
1047
-
1048
- /**
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.
1086
- */
1087
- private processProducerMessage(line: string, producerSocket: Socket): void {
1088
- let msg: any
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)
1094
- }
1095
-
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
-
1125
- if (msg.type === 'windowAck') {
1126
- this.emit('windowAck', msg)
1127
- const rosterChanged = this.updateTrackedWindowsFromAck(msg)
1128
-
1129
- const ackId = this.normalizeRequestId(msg.id)
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
- }
1151
- }
1152
- }
1153
- if (rosterChanged) this.broadcastWindowStateSync()
1154
- return
1155
- }
1156
-
1157
- let messageOut = msg
1158
-
1159
- if (msg.type === 'windowClosed') {
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)
1167
- }
1168
- this.emit('windowClosed', messageOut)
1169
- // Broadcast to all producers
1170
- this.broadcastToProducers(JSON.stringify(messageOut))
1171
- this.broadcastWindowStateSync()
1172
- }
1173
-
1174
- if (msg.type === 'terminalExited') {
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))
1182
- }
1183
-
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)
1195
- }
1196
-
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
- }
1205
-
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
- }
1215
-
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)
1308
- return
1309
- }
1310
-
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)
1357
- }
1358
-
1359
- this.emit('message', messageOut)
1360
- }
1361
-
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
1379
- }
1380
-
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
1403
- }
1404
-
1405
- /**
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.
1410
- */
1411
- private sendWindowCommand(windowPayload: Record<string, any>): Promise<WindowAckResult> {
1412
- return new Promise<WindowAckResult>(async (resolve, reject) => {
1413
- const timeoutMs = this.options.requestTimeoutMs || 10000
1414
-
1415
- // Auto-start if needed
1416
- if (!this._mode) {
1417
- await this.listen()
1418
- }
1419
-
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
- }
1427
-
1428
- const rawId = randomUUID()
1429
- const id = this.normalizeRequestId(rawId) || rawId
1430
-
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 })
1439
-
1440
- const payload = {
1441
- id,
1442
- status: 'processing',
1443
- window: windowPayload,
1444
- timestamp: new Date().toISOString(),
1445
- }
1446
-
1447
- this._controlClient.socket.write(JSON.stringify(payload) + '\n')
1448
- return
1449
- }
1450
-
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
- }
1461
- }
1462
-
1463
- const rawId = randomUUID()
1464
- const id = this.normalizeRequestId(rawId) || rawId
1465
-
1466
- const timer = setTimeout(() => {
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
- }
1471
- }, timeoutMs)
1472
-
1473
- this.registerPendingOperation(id, windowPayload.action, { resolve, reject, timer })
1474
-
1475
- const payload = {
1476
- id,
1477
- status: 'processing',
1478
- window: windowPayload,
1479
- timestamp: new Date().toISOString(),
1480
- }
1481
- const sent = this.send(payload)
1482
-
1483
- if (!sent) {
1484
- const p = this.takePendingRequest(id)
1485
- const error = `Failed to send window ${windowPayload.action}: client disconnected`
1486
- this.setState({ lastError: error })
1487
- if (p) {
1488
- p.resolve({ ok: false, error, code: 'Disconnected' })
1489
- }
1490
- }
1491
- })
1492
- }
1493
-
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
- })
1600
- }
1601
- }
1602
-
1603
- export default WindowManager