@shiplightai/mcp 0.1.44 → 0.1.46

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.
@@ -0,0 +1,774 @@
1
+ import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
2
+
3
+ const BADGE = {
4
+ on: { text: 'ON', color: '#FF5A36' },
5
+ off: { text: '', color: '#000000' },
6
+ connecting: { text: '…', color: '#F59E0B' },
7
+ error: { text: '!', color: '#B91C1C' },
8
+ unconfigured: { text: '?', color: '#9CA3AF' },
9
+ }
10
+
11
+ /** @type {WebSocket|null} */
12
+ let relayWs = null
13
+ /** @type {Promise<void>|null} */
14
+ let relayConnectPromise = null
15
+ let nextSession = 1
16
+
17
+ /** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string}>} */
18
+ const tabs = new Map()
19
+ /** @type {Map<string, number>} */
20
+ const tabBySession = new Map()
21
+
22
+ /** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
23
+ const pending = new Map()
24
+
25
+ // Per-tab operation locks prevent double-attach races.
26
+ /** @type {Set<number>} */
27
+ const tabOperationLocks = new Set()
28
+
29
+ // Tabs currently in a detach/re-attach cycle after navigation.
30
+ /** @type {Set<number>} */
31
+ const reattachPending = new Set()
32
+
33
+ // Reconnect state for exponential backoff.
34
+ let reconnectAttempt = 0
35
+ let reconnectTimer = null
36
+
37
+ /** @returns {Promise<number|null>} configured port or null if not set */
38
+ async function getRelayPort() {
39
+ const stored = await chrome.storage.local.get(['relayPort'])
40
+ const raw = stored.relayPort
41
+ const n = Number.parseInt(String(raw || ''), 10)
42
+ if (!Number.isFinite(n) || n <= 0 || n > 65535) return null
43
+ return n
44
+ }
45
+
46
+ function setBadge(tabId, kind) {
47
+ const cfg = BADGE[kind]
48
+ void chrome.action.setBadgeText({ tabId, text: cfg.text })
49
+ void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
50
+ void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
51
+ }
52
+
53
+ /** Badge kind reflecting current relay connection state. */
54
+ function relayBadgeKind() {
55
+ return relayWs?.readyState === WebSocket.OPEN ? 'on' : 'connecting'
56
+ }
57
+
58
+ // Persist attached tab state to survive MV3 service worker restarts.
59
+ async function persistState() {
60
+ try {
61
+ const tabEntries = []
62
+ for (const [tabId, tab] of tabs.entries()) {
63
+ if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
64
+ tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId })
65
+ }
66
+ }
67
+ await chrome.storage.session.set({
68
+ persistedTabs: tabEntries,
69
+ nextSession,
70
+ })
71
+ } catch {
72
+ // chrome.storage.session may not be available in all contexts.
73
+ }
74
+ }
75
+
76
+ // Rehydrate tab state on service worker startup.
77
+ async function rehydrateState() {
78
+ try {
79
+ const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
80
+ if (stored.nextSession) {
81
+ nextSession = Math.max(nextSession, stored.nextSession)
82
+ }
83
+ const entries = stored.persistedTabs || []
84
+ // Phase 1: optimistically restore state and badges.
85
+ for (const entry of entries) {
86
+ tabs.set(entry.tabId, {
87
+ state: 'connected',
88
+ sessionId: entry.sessionId,
89
+ targetId: entry.targetId,
90
+ })
91
+ tabBySession.set(entry.sessionId, entry.tabId)
92
+ setBadge(entry.tabId, 'on')
93
+ }
94
+ // Phase 2: validate asynchronously, remove dead tabs.
95
+ for (const entry of entries) {
96
+ try {
97
+ await chrome.tabs.get(entry.tabId)
98
+ await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
99
+ expression: '1',
100
+ returnByValue: true,
101
+ })
102
+ } catch {
103
+ tabs.delete(entry.tabId)
104
+ tabBySession.delete(entry.sessionId)
105
+ setBadge(entry.tabId, 'off')
106
+ }
107
+ }
108
+ } catch {
109
+ // Ignore rehydration errors.
110
+ }
111
+ }
112
+
113
+ async function ensureRelayConnection() {
114
+ if (relayWs && relayWs.readyState === WebSocket.OPEN) return
115
+ if (relayConnectPromise) return await relayConnectPromise
116
+
117
+ relayConnectPromise = (async () => {
118
+ const port = await getRelayPort()
119
+ if (!port) throw new Error('No relay port configured')
120
+ const httpBase = `http://127.0.0.1:${port}`
121
+ const wsUrl = buildRelayWsUrl(port)
122
+
123
+ // Fast preflight: is the relay server up?
124
+ try {
125
+ await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
126
+ } catch (err) {
127
+ throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
128
+ }
129
+
130
+ const ws = new WebSocket(wsUrl)
131
+ relayWs = ws
132
+ // Bind message handler before open so an immediate first frame cannot be missed.
133
+ ws.onmessage = (event) => {
134
+ if (ws !== relayWs) return
135
+ void whenReady(() => onRelayMessage(String(event.data || '')))
136
+ }
137
+
138
+ await new Promise((resolve, reject) => {
139
+ const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
140
+ ws.onopen = () => {
141
+ clearTimeout(t)
142
+ resolve()
143
+ }
144
+ ws.onerror = () => {
145
+ clearTimeout(t)
146
+ reject(new Error('WebSocket connect failed'))
147
+ }
148
+ ws.onclose = (ev) => {
149
+ clearTimeout(t)
150
+ reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
151
+ }
152
+ })
153
+
154
+ // Bind permanent handlers.
155
+ ws.onclose = () => {
156
+ if (ws !== relayWs) return
157
+ onRelayClosed('closed')
158
+ }
159
+ ws.onerror = () => {
160
+ if (ws !== relayWs) return
161
+ onRelayClosed('error')
162
+ }
163
+ })()
164
+
165
+ try {
166
+ await relayConnectPromise
167
+ reconnectAttempt = 0
168
+ } finally {
169
+ relayConnectPromise = null
170
+ }
171
+ }
172
+
173
+ // Relay closed — update badges, reject pending requests, auto-reconnect.
174
+ function onRelayClosed(reason) {
175
+ relayWs = null
176
+
177
+ for (const [id, p] of pending.entries()) {
178
+ pending.delete(id)
179
+ p.reject(new Error(`Relay disconnected (${reason})`))
180
+ }
181
+
182
+ reattachPending.clear()
183
+
184
+ for (const [tabId, tab] of tabs.entries()) {
185
+ if (tab.state === 'connected') {
186
+ setBadge(tabId, 'connecting')
187
+ void chrome.action.setTitle({
188
+ tabId,
189
+ title: 'Shiplight AI: relay reconnecting…',
190
+ })
191
+ }
192
+ }
193
+
194
+ scheduleReconnect()
195
+ }
196
+
197
+ function scheduleReconnect() {
198
+ if (reconnectTimer) {
199
+ clearTimeout(reconnectTimer)
200
+ reconnectTimer = null
201
+ }
202
+
203
+ const delay = reconnectDelayMs(reconnectAttempt)
204
+ reconnectAttempt++
205
+
206
+ console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
207
+
208
+ reconnectTimer = setTimeout(async () => {
209
+ reconnectTimer = null
210
+ try {
211
+ await ensureRelayConnection()
212
+ reconnectAttempt = 0
213
+ console.log('Reconnected successfully')
214
+ await reannounceAttachedTabs()
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err)
217
+ console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
218
+ if (!isRetryableReconnectError(err)) {
219
+ return
220
+ }
221
+ scheduleReconnect()
222
+ }
223
+ }, delay)
224
+ }
225
+
226
+ function cancelReconnect() {
227
+ if (reconnectTimer) {
228
+ clearTimeout(reconnectTimer)
229
+ reconnectTimer = null
230
+ }
231
+ reconnectAttempt = 0
232
+ }
233
+
234
+ // Re-announce all attached tabs to the relay after reconnect.
235
+ async function reannounceAttachedTabs() {
236
+ for (const [tabId, tab] of tabs.entries()) {
237
+ if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
238
+
239
+ // Verify debugger is still attached.
240
+ try {
241
+ await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
242
+ expression: '1',
243
+ returnByValue: true,
244
+ })
245
+ } catch {
246
+ tabs.delete(tabId)
247
+ if (tab.sessionId) tabBySession.delete(tab.sessionId)
248
+ setBadge(tabId, 'off')
249
+ void chrome.action.setTitle({
250
+ tabId,
251
+ title: 'Shiplight AI (click to attach/detach)',
252
+ })
253
+ continue
254
+ }
255
+
256
+ // Send fresh attach event to relay.
257
+ try {
258
+ const info = /** @type {any} */ (
259
+ await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
260
+ )
261
+ const targetInfo = info?.targetInfo
262
+
263
+ sendToRelay({
264
+ method: 'forwardCDPEvent',
265
+ params: {
266
+ method: 'Target.attachedToTarget',
267
+ params: {
268
+ sessionId: tab.sessionId,
269
+ targetInfo: { ...targetInfo, tabId, attached: true },
270
+ waitingForDebugger: false,
271
+ },
272
+ },
273
+ })
274
+
275
+ setBadge(tabId, 'on')
276
+ void chrome.action.setTitle({
277
+ tabId,
278
+ title: 'Shiplight AI: attached (click to detach)',
279
+ })
280
+ } catch {
281
+ setBadge(tabId, 'on')
282
+ }
283
+ }
284
+
285
+ await persistState()
286
+ }
287
+
288
+ function sendToRelay(payload) {
289
+ const ws = relayWs
290
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
291
+ throw new Error('Relay not connected')
292
+ }
293
+ ws.send(JSON.stringify(payload))
294
+ }
295
+
296
+ function requestFromRelay(command) {
297
+ const id = command.id
298
+ return new Promise((resolve, reject) => {
299
+ const timer = setTimeout(() => {
300
+ pending.delete(id)
301
+ reject(new Error('Relay request timeout (30s)'))
302
+ }, 30000)
303
+ pending.set(id, {
304
+ resolve: (v) => { clearTimeout(timer); resolve(v) },
305
+ reject: (e) => { clearTimeout(timer); reject(e) },
306
+ })
307
+ try {
308
+ sendToRelay(command)
309
+ } catch (err) {
310
+ clearTimeout(timer)
311
+ pending.delete(id)
312
+ reject(err instanceof Error ? err : new Error(String(err)))
313
+ }
314
+ })
315
+ }
316
+
317
+ async function onRelayMessage(text) {
318
+ /** @type {any} */
319
+ let msg
320
+ try {
321
+ msg = JSON.parse(text)
322
+ } catch {
323
+ return
324
+ }
325
+
326
+ // Handle CDP command from relay
327
+ if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
328
+ const { method, params, sessionId } = msg.params || {}
329
+ const tabId = tabBySession.get(sessionId)
330
+ if (!tabId) {
331
+ // No tab found for this session
332
+ sendToRelay({
333
+ id: msg.id,
334
+ error: { message: `No tab found for session ${sessionId}` },
335
+ })
336
+ return
337
+ }
338
+
339
+ try {
340
+ // Add timeout wrapper - chrome.debugger.sendCommand can hang indefinitely
341
+ const result = await Promise.race([
342
+ chrome.debugger.sendCommand({ tabId }, method, params),
343
+ new Promise((_, reject) =>
344
+ setTimeout(() => reject(new Error(`CDP command timeout: ${method}`)), 25000)
345
+ ),
346
+ ])
347
+ sendToRelay({
348
+ id: msg.id,
349
+ result,
350
+ })
351
+ } catch (err) {
352
+ const errorMsg = err instanceof Error ? err.message : String(err)
353
+
354
+ // Handle unsupported Browser.* commands with fake responses
355
+ if (method === 'Browser.getVersion') {
356
+ sendToRelay({
357
+ id: msg.id,
358
+ result: {
359
+ protocolVersion: '1.3',
360
+ product: 'Chrome',
361
+ revision: '@000000',
362
+ userAgent: navigator.userAgent,
363
+ jsVersion: '0.0.0',
364
+ },
365
+ })
366
+ return
367
+ }
368
+
369
+ if (method === 'Browser.setDownloadBehavior') {
370
+ // Just return success - downloads won't work but we can ignore this
371
+ sendToRelay({
372
+ id: msg.id,
373
+ result: {},
374
+ })
375
+ return
376
+ }
377
+
378
+ sendToRelay({
379
+ id: msg.id,
380
+ error: { message: errorMsg },
381
+ })
382
+ }
383
+ return
384
+ }
385
+
386
+ // Handle getActiveTab request from relay
387
+ if (msg && typeof msg.id === 'number' && msg.method === 'getActiveTab') {
388
+ try {
389
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true })
390
+ const tabId = activeTab?.id
391
+
392
+ if (!tabId) {
393
+ sendToRelay({
394
+ id: msg.id,
395
+ result: { sessionId: null, tabId: null },
396
+ })
397
+ return
398
+ }
399
+
400
+ const attachedTab = tabs.get(tabId)
401
+
402
+ if (attachedTab && attachedTab.state === 'connected') {
403
+ // Active tab is already attached
404
+ sendToRelay({
405
+ id: msg.id,
406
+ result: {
407
+ sessionId: attachedTab.sessionId,
408
+ tabId: tabId,
409
+ },
410
+ })
411
+ } else {
412
+ // Active tab is not attached
413
+ sendToRelay({
414
+ id: msg.id,
415
+ result: { sessionId: null, tabId: null },
416
+ })
417
+ }
418
+ } catch (err) {
419
+ sendToRelay({
420
+ id: msg.id,
421
+ error: { message: err instanceof Error ? err.message : String(err) },
422
+ })
423
+ }
424
+ return
425
+ }
426
+
427
+ // Handle response to our CDP commands
428
+ if (msg && typeof msg.id === 'number') {
429
+ const p = pending.get(msg.id)
430
+ if (p) {
431
+ pending.delete(msg.id)
432
+ if (msg.error) {
433
+ p.reject(new Error(msg.error.message || 'Command failed'))
434
+ } else {
435
+ p.resolve(msg.result)
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ // Attach/detach flow
442
+ async function toggleAttach(tabId) {
443
+ if (tabOperationLocks.has(tabId)) {
444
+ console.warn(`Tab ${tabId} is locked (concurrent attach/detach)`)
445
+ return
446
+ }
447
+ tabOperationLocks.add(tabId)
448
+
449
+ try {
450
+ const tab = tabs.get(tabId)
451
+ if (tab && tab.state === 'connected') {
452
+ await doDetach(tabId)
453
+ } else {
454
+ await doAttach(tabId)
455
+ }
456
+ } catch (err) {
457
+ console.error(`Toggle attach failed for tab ${tabId}:`, err)
458
+ setBadge(tabId, 'error')
459
+ void chrome.action.setTitle({
460
+ tabId,
461
+ title: `Shiplight AI: error (${err instanceof Error ? err.message : String(err)})`,
462
+ })
463
+ } finally {
464
+ tabOperationLocks.delete(tabId)
465
+ }
466
+ }
467
+
468
+ async function doAttach(tabId) {
469
+ // Ensure relay connection
470
+ try {
471
+ await ensureRelayConnection()
472
+ } catch (err) {
473
+ throw new Error(`Cannot connect to relay: ${err instanceof Error ? err.message : String(err)}`)
474
+ }
475
+
476
+ // Attach debugger
477
+ try {
478
+ await chrome.debugger.attach({ tabId }, '1.3')
479
+ } catch (err) {
480
+ throw new Error(`Cannot attach debugger: ${err instanceof Error ? err.message : String(err)}`)
481
+ }
482
+
483
+ // Get target info
484
+ const info = /** @type {any} */ (
485
+ await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
486
+ )
487
+ const targetInfo = info?.targetInfo
488
+ if (!targetInfo) {
489
+ await chrome.debugger.detach({ tabId })
490
+ throw new Error('No target info')
491
+ }
492
+
493
+ // Generate session ID
494
+ const sessionId = `sl-tab-${nextSession++}`
495
+
496
+ // Store tab state
497
+ tabs.set(tabId, {
498
+ state: 'connected',
499
+ sessionId,
500
+ targetId: targetInfo.targetId,
501
+ })
502
+ tabBySession.set(sessionId, tabId)
503
+
504
+ // Notify relay
505
+ sendToRelay({
506
+ method: 'forwardCDPEvent',
507
+ params: {
508
+ method: 'Target.attachedToTarget',
509
+ params: {
510
+ sessionId,
511
+ targetInfo: { ...targetInfo, tabId, attached: true },
512
+ waitingForDebugger: false,
513
+ },
514
+ },
515
+ })
516
+
517
+ setBadge(tabId, 'on')
518
+ void chrome.action.setTitle({
519
+ tabId,
520
+ title: 'Shiplight AI: attached (click to detach)',
521
+ })
522
+
523
+ await persistState()
524
+ }
525
+
526
+ async function doDetach(tabId) {
527
+ const tab = tabs.get(tabId)
528
+ if (!tab) return
529
+
530
+ // Notify relay
531
+ if (tab.sessionId) {
532
+ try {
533
+ sendToRelay({
534
+ method: 'forwardCDPEvent',
535
+ params: {
536
+ method: 'Target.detachedFromTarget',
537
+ params: {
538
+ sessionId: tab.sessionId,
539
+ },
540
+ },
541
+ })
542
+ } catch {
543
+ // Ignore relay errors on detach
544
+ }
545
+ }
546
+
547
+ // Detach debugger
548
+ try {
549
+ await chrome.debugger.detach({ tabId })
550
+ } catch {
551
+ // Ignore detach errors
552
+ }
553
+
554
+ // Clean up state
555
+ if (tab.sessionId) {
556
+ tabBySession.delete(tab.sessionId)
557
+ }
558
+ tabs.delete(tabId)
559
+
560
+ setBadge(tabId, 'off')
561
+ void chrome.action.setTitle({
562
+ tabId,
563
+ title: 'Shiplight AI (click to attach/detach)',
564
+ })
565
+
566
+ await persistState()
567
+ }
568
+
569
+ // CDP event forwarding
570
+ chrome.debugger.onEvent.addListener((source, method, params) => {
571
+ const tabId = source.tabId
572
+ if (!tabId) return
573
+
574
+ const tab = tabs.get(tabId)
575
+ if (!tab || tab.state !== 'connected' || !tab.sessionId) return
576
+
577
+ // Forward CDP events to relay
578
+ try {
579
+ sendToRelay({
580
+ method: 'forwardCDPEvent',
581
+ params: {
582
+ method,
583
+ params,
584
+ sessionId: tab.sessionId,
585
+ },
586
+ })
587
+ } catch {
588
+ // Ignore relay errors
589
+ }
590
+ })
591
+
592
+ // Debugger detach handler
593
+ chrome.debugger.onDetach.addListener(async (source, reason) => {
594
+ const tabId = source.tabId
595
+ if (!tabId) return
596
+
597
+ const tab = tabs.get(tabId)
598
+ if (!tab) return
599
+
600
+ console.log(`Debugger detached from tab ${tabId}: ${reason}`)
601
+
602
+ // Clean up
603
+ if (tab.sessionId) {
604
+ tabBySession.delete(tab.sessionId)
605
+ }
606
+ tabs.delete(tabId)
607
+
608
+ setBadge(tabId, 'off')
609
+ void chrome.action.setTitle({
610
+ tabId,
611
+ title: 'Shiplight AI (click to attach/detach)',
612
+ })
613
+
614
+ await persistState()
615
+ })
616
+
617
+ // Navigation handling: auto-reattach
618
+ chrome.webNavigation.onCommitted.addListener((details) => {
619
+ const tabId = details.tabId
620
+ if (!tabId || details.frameId !== 0) return
621
+
622
+ const tab = tabs.get(tabId)
623
+ if (!tab || tab.state !== 'connected') return
624
+
625
+ // Mark for reattach and preserve badge during navigation
626
+ reattachPending.add(tabId)
627
+ setBadge(tabId, relayBadgeKind())
628
+
629
+ // Reattach after navigation completes (self-removing listener to avoid accumulation)
630
+ const onNavCompleted = async (nav) => {
631
+ if (nav.tabId !== tabId || nav.frameId !== 0) return
632
+ chrome.webNavigation.onCompleted.removeListener(onNavCompleted)
633
+
634
+ reattachPending.delete(tabId)
635
+
636
+ // Re-announce to relay and restore badge
637
+ try {
638
+ const info = /** @type {any} */ (
639
+ await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
640
+ )
641
+ const targetInfo = info?.targetInfo
642
+
643
+ sendToRelay({
644
+ method: 'forwardCDPEvent',
645
+ params: {
646
+ method: 'Target.attachedToTarget',
647
+ params: {
648
+ sessionId: tab.sessionId,
649
+ targetInfo: { ...targetInfo, tabId, attached: true },
650
+ waitingForDebugger: false,
651
+ },
652
+ },
653
+ })
654
+
655
+ // Restore badge — Chrome may clear per-tab badge state on navigation
656
+ setBadge(tabId, relayBadgeKind())
657
+ } catch {
658
+ // Ignore reattach errors
659
+ }
660
+ }
661
+ chrome.webNavigation.onCompleted.addListener(onNavCompleted, { url: [{ urlMatches: '.*' }] })
662
+ })
663
+
664
+ // Extension toolbar click
665
+ chrome.action.onClicked.addListener(async (tab) => {
666
+ const port = await getRelayPort()
667
+ if (!port) {
668
+ // No port configured — open options page
669
+ chrome.runtime.openOptionsPage()
670
+ return
671
+ }
672
+ if (tab.id) {
673
+ await toggleAttach(tab.id)
674
+ }
675
+ })
676
+
677
+ // Tab activation handler — notify relay when user switches to a registered tab
678
+ chrome.tabs.onActivated.addListener((activeInfo) => {
679
+ const tabId = activeInfo.tabId
680
+ const tab = tabs.get(tabId)
681
+ if (!tab || tab.state !== 'connected' || !tab.sessionId) return
682
+
683
+ try {
684
+ sendToRelay({
685
+ method: 'forwardCDPEvent',
686
+ params: {
687
+ method: 'Target.activateTarget',
688
+ params: {
689
+ targetId: tab.targetId,
690
+ sessionId: tab.sessionId,
691
+ },
692
+ },
693
+ })
694
+ } catch {
695
+ // Ignore relay errors — tab activation is best-effort
696
+ }
697
+ })
698
+
699
+ // Tab close handler
700
+ chrome.tabs.onRemoved.addListener(async (tabId) => {
701
+ const tab = tabs.get(tabId)
702
+ if (tab) {
703
+ await doDetach(tabId)
704
+ }
705
+ })
706
+
707
+ // Tab replaced handler (when Chrome replaces a tab with a new ID)
708
+ chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
709
+ const tab = tabs.get(removedTabId)
710
+ if (!tab) return
711
+
712
+ // Move tab state to new ID
713
+ tabs.delete(removedTabId)
714
+ tabs.set(addedTabId, tab)
715
+ if (tab.sessionId) {
716
+ tabBySession.set(tab.sessionId, addedTabId)
717
+ }
718
+
719
+ // Update badge on new tab
720
+ setBadge(addedTabId, relayBadgeKind())
721
+
722
+ void persistState()
723
+ })
724
+
725
+ // Keepalive alarm
726
+ chrome.alarms.create('keepalive', { periodInMinutes: 0.5 })
727
+ chrome.alarms.onAlarm.addListener((alarm) => {
728
+ if (alarm.name === 'keepalive') {
729
+ // Restore badge state for all connected tabs (service worker restart may clear badges)
730
+ console.debug('Keepalive: restoring badges for', tabs.size, 'tab(s)')
731
+ for (const [tabId, tab] of tabs.entries()) {
732
+ if (tab.state === 'connected') {
733
+ setBadge(tabId, relayBadgeKind())
734
+ }
735
+ }
736
+ }
737
+ })
738
+
739
+ // Service worker startup
740
+ let whenReadyQueue = []
741
+ let serviceReady = false
742
+
743
+ async function init() {
744
+ await rehydrateState()
745
+
746
+ // Show unconfigured state if no port is set
747
+ const port = await getRelayPort()
748
+ if (!port) {
749
+ void chrome.action.setBadgeText({ text: '?' })
750
+ void chrome.action.setBadgeBackgroundColor({ color: '#9CA3AF' })
751
+ void chrome.action.setBadgeTextColor({ color: '#FFFFFF' }).catch(() => {})
752
+ void chrome.action.setTitle({ title: 'Shiplight AI: no relay port configured (click to configure)' })
753
+ }
754
+
755
+ serviceReady = true
756
+ for (const fn of whenReadyQueue) {
757
+ try {
758
+ await fn()
759
+ } catch (err) {
760
+ console.error('whenReady callback error:', err)
761
+ }
762
+ }
763
+ whenReadyQueue = []
764
+ }
765
+
766
+ function whenReady(fn) {
767
+ if (serviceReady) {
768
+ void fn()
769
+ } else {
770
+ whenReadyQueue.push(fn)
771
+ }
772
+ }
773
+
774
+ void init()