// KueSync — Bitfocus Companion module entry point
// © 2026 Krometis Analytics. All rights reserved.
//
// MULTI-MACHINE BY DESIGN: one connection drives many presentation Macs. Enter one agent IP per
// line on the config page; the module opens a WebSocket to each and BROADCASTS every command to
// all of them, using absolute slide targets so the whole fleet stays in sync. Slide 1 of the list
// is the "primary" used for slide-number feedback.

import { InstanceBase, InstanceStatus, runEntrypoint } from '@companion-module/base'
import WebSocket from 'ws'
import http from 'http'
import { getActions } from './actions.js'
import { getFeedbacks } from './feedbacks.js'
import { getVariableDefinitions, updateVariables } from './variables.js'
import { getPresets } from './presets.js'
import { LicenseManager, evaluateEnforcement, evaluateSeatEnforcement, verifyLicenseKey, ENFORCE } from './license.js'
import { shouldRefresh, acceptRefreshedKey, requestRefresh, decodeKeyPayload, DEFAULT_REFRESH_ENDPOINT } from './license-refresh.js'
import { resolveTrialStart, persistTrialStart, loadLastSeen, persistLastSeen, loadLicState, persistLicState, identityKeyPath } from './trialstore.js'
import { loadOrCreateIdentity, FileKeyStore } from './identity.js'
import { buildCue, dwellFor, nextCueSlide, parseCueSheet } from './cuesheet.js'
import { UpgradeScripts } from './upgrades.js'
import { fmtClock, stopClock, startClock, pauseClock, resumeClock, finishClock, clockReadout, sourceLabel } from './mediaclock.js'

const DEFAULT_PORT = 37100

// Parse "host", "host:port", or bracketed IPv6 "[fe80::1]:port" / "fe80::1". Splitting on the LAST
// colon (only when there's exactly one, or brackets are used) keeps IPv6 link-local addresses intact.
function splitHostPort(s, def = DEFAULT_PORT) {
  s = String(s || '').trim()
  if (!s) return { host: '', port: def }
  if (s[0] === '[') {                                  // [ipv6]:port
    const e = s.indexOf(']')
    if (e > 0) { const h = s.slice(1, e); const rest = s.slice(e + 1); const p = rest.startsWith(':') ? Number(rest.slice(1)) : 0; return { host: h, port: p || def } }
  }
  if ((s.match(/:/g) || []).length === 1) {            // exactly one colon → host:port (IPv4 / name)
    const [h, p] = s.split(':'); return { host: h.trim(), port: Number(p) || def }
  }
  return { host: s, port: def }                        // bare IPv6 or hostname, no port
}

class KueSyncInstance extends InstanceBase {
  async init(config) {
    this.config = config
    this.cmdId = 0
    this.targets = [] // [{ host, port, ws, connected, state, reconnectTimer }]
    this.cue = { running: false, paused: false, ticker: null, dwellRemaining: 0, targetSlide: 0, loop: false }
    // Video clock — TRT/remaining/elapsed for the current slide's media. trtMs/source/trigger come from
    // the leader agent (measured on Windows/PowerPoint) or the cue sheet (planned, on macOS/Keynote).
    this.video = { trtMs: 0, source: 'none', trigger: 'none', playClick: -1, armed: false, lastSlide: -1, clock: stopClock() }
    this.intendedSlide = null      // optimistic slide counter (fast-click race fix)
    // Cue light (hardware-style): mirrors the fleet's advances — GREEN on a forward move, RED on
    // a backward move — by watching the primary's slide+build position. Output-only (a feedback paints
    // the button); drop the cue_light feedback on one key or tile it across many to build a big lamp.
    this.cueLight = { state: 'idle', lastPos: null, timer: null, lastFireAt: 0 }
    this._lastAdvFireAt = 0   // 0.10.29: last time an advance actually fired — drives the rapid-tap debounce
    this.lastCmdAt = 0
    this.IDLE_RECONCILE_MS = 800    // after this idle gap, trust agent feedback over the counter
    this.SYNC_SETTLE_MS = 1500      // grace after a command before drift is flagged (agents catching up)
    this.healthTimer = null         // 1Hz health/staleness + auto-resync evaluator
    this.timerEdgeTimer = null      // 0.12.15: 4Hz edge detector for hardware triggers (see _timerEdgeTick)
    this._driftSince = 0            // when the fleet first went out of sync (for auto-resync hold)
    // 0.12.15 EDGE STATE for driving external hardware. Companion's feedback model is LEVEL-based — a boolean is
    // true only while its condition holds — so a Companion trigger evaluated slower than the crossing, or a GPI
    // that needs a momentary pulse, can miss a threshold the timer passes through in a fraction of a second.
    // These monotonic counters tick ONCE on each crossing and are published as variables; a trigger watching a
    // variable-change never misses an edge, however briefly the level condition held.
    this._zeroSeq = 0               // ++ each time the timer crosses INTO overtime (a hard-out edge)
    this._warnSeq = 0               // ++ each time it crosses INTO the warn window
    this._wasOvertime = false       // last-seen level, so we fire on the TRANSITION only
    this._wasWarn = false
    this._belowLatch = new Map()    // per-feedback hysteresis latch for cue_time_remaining_below
    this._cueTakeAt = 0             // wall-clock we first saw THIS cue on air (drives cue_take_at)
    // 0.12.49.1 LEADER CONFIG MIRROR. The look surfaces (backgrounds, chroma text, Kue-Light pips) and the notes
    // SOURCE / backup live in the LEADER's config, not on the WS wire. We READ them with an open GET /leader/config
    // and WRITE them with a control-password POST /leader/config — the exact path KueSync Control uses — so these
    // become live Companion controls with NO agent change. leaderCfg is the last-read snapshot that drives the
    // look/notes feedbacks + variables and lets the cycle/toggle actions know the current value.
    this.leaderCfg = {}
    this._cfgInflight = false
    this._cfgTick = 0
    // 0.12.49.2 TIMER-CUE MIRROR. The full cue list (names, speaker, per-cue projected out-times, over/under)
    // lives on the leader and is read with GET /leader/timercues (control-password gated). Authoring — add /
    // update / delete / move / clear / load — is POST /leader/timercues with the same token, so Companion can
    // build and fire a named cue list, matching the native KueSync Control screen, with NO agent change. This is
    // the last-read snapshot that drives the cue picker, the per-cue TAKE presets, and the cue/back-timing vars.
    this.cuesData = { cues: [], count: 0, pgmIndex: -1, pvwIndex: -1, endsAt: 0, plannedEndsAt: 0, deltaMs: 0, totalRealMs: 0 }
    this._cuesInflight = false
    this._lastCueSig = ''

    // Trial start is resolved from a persistent file (survives module reinstall) AND the config,
    // taking the EARLIEST so re-adding the connection / reinstalling cannot restart the 7 days.
    const effectiveTrialStart = resolveTrialStart(config.trialStart)
    config.trialStart = effectiveTrialStart
    this.saveConfig(config)
    // Monotonic clock: never let "now" go backward, so rolling the system clock back can't extend
    // the trial. maxSeen is a high-water mark persisted to disk.
    this.maxSeen = Math.max(loadLastSeen(), Date.now())
    persistLastSeen(this.maxSeen)
    const monotonicNow = () => { this.maxSeen = Math.max(this.maxSeen, Date.now()); return this.maxSeen }
    this._seenTimer = setInterval(() => persistLastSeen(this.maxSeen), 5 * 60 * 1000)
    this._seenTimer.unref?.()
    this.license = new LicenseManager({
      key: config.licenseKey || '',
      trialStart: effectiveTrialStart,
      now: monotonicNow,
      saveTrialStart: (ts) => { persistTrialStart(ts); this.config.trialStart = ts; this.saveConfig(this.config) },
    })
    // 0.10.0 seamless renewal (Companion-driven topology): throttle + in-flight guard + injectable transport.
    this._licRefreshAt = 0
    this._licRefreshing = false
    this._fetchRefresh = requestRefresh
    // 0.12.52 FULL LICENSING PARITY WITH THE AGENT. The Companion now: (a) honours a server REVOKE, and (b)
    // participates in SEAT / online activation via a signed machine identity — using the SAME pure evaluators
    // (evaluateEnforcement + evaluateSeatEnforcement) and the SAME reinstall-proof persisted state as a leader
    // agent. FAIL-OPEN throughout: an unreachable server, an eval error, or a missing identity NEVER blocks a
    // show; only a CONFIRMED verdict (server-affirmed revoke, or the ≤24h seat/activation ladder fully elapsed)
    // hard-stops the NEXT command — a running cue is never interrupted, and legacy keys are never seat-capped.
    this._licState = loadLicState()
    try { this.identity = loadOrCreateIdentity({ keystore: new FileKeyStore(identityKeyPath()), candidateId: '' }) }
    catch { this.identity = null }
    // A SEAT is one fleet. Default the Companion's fleet scope to its own stable identity keyId (overridable via
    // config.fleetId) so re-adding the connection keeps the same seat.
    this._fleetId = () => (this.config.fleetId || (this.identity && this.identity.keyId) || '')
    this._noteKeyFirstRun(config.licenseKey || '')           // stamp firstRunAt for the installed key (starts the activation clock)
    this._licCheckInAt = 0                                    // last periodic check-in (revocation + seat), separate from renewal
    this._licCheckInReachable = true                         // adaptive cadence: offline ⇒ fast retry until back online
    this._checkInIntervalMs = Number(process.env.KUESYNC_LICENSE_CHECKIN_MS) || (6 * 3600_000 + Math.floor(Math.random() * 6 * 3600_000))   // jittered 6–12h online
    this._checkInRetryMs = Number(process.env.KUESYNC_LICENSE_RETRY_MS) || (3 * 60_000)                                                     // ~3m while offline
    this._enfBlocked = false                                 // cached hard-stop verdict (computed OFF the command path)
    this._recomputeLicense()

    this.setActionDefinitions(getActions(this))
    this.setFeedbackDefinitions(getFeedbacks(this))
    this.setVariableDefinitions(getVariableDefinitions())
    this.setPresetDefinitions(getPresets(this))
    // 0.12.0 CUE-LIGHT RE-BIND: on a module RELOAD (new version, or Companion quit/reopen) Companion loads the
    // existing buttons but does NOT always re-bind their advanced feedbacks to this fresh instance — so the live
    // checkFeedbacks('cue_light') from a cue never reached them and the lamp stayed dead until the operator
    // manually removed + re-added the button (a re-add re-runs `subscribe`). subscribeFeedbacks() re-runs every
    // feedback's `subscribe` against this instance — the programmatic equivalent of re-adding ALL buttons — so
    // cue lights (and every other feedback) come up live after a reload with no manual re-add. Deferred a tick so
    // Companion has finished loading the button instances first; guarded so a missing API can never throw.
    setTimeout(() => { try { this.subscribeFeedbacks() } catch {} try { this.checkFeedbacks() } catch {} }, 0)
    // NOTE (cue light after a Companion restart): Companion 4.3.4 does not re-render a PLACED feedback (advanced
    // OR boolean) after the app restarts until the button is manually re-added — confirmed across many approaches
    // (subscribe-on-add, subscribeFeedbacks, typed/bare checkFeedbacks, advanced→boolean, setFeedbackDefinitions
    // re-registration). The module fires every cue correctly and the preset previews update live; it is a
    // Companion-core reload bug, reported upstream. No module workaround reaches placed instances, so we don't
    // ship one. Lamps work for the whole session once placed; a restart needs a one-time re-add.
    this.refreshLicenseUi()
    this.connectAll()
    // Health monitor: a connected socket isn't the same as a healthy machine. Re-evaluate staleness
    // (an agent that stopped heart-beating), refresh the UI, and run optional auto-resync once a
    // second — independent of incoming messages, so a machine that goes silent actually turns red.
    this.healthTimer = setInterval(() => {
      // CRASH-PROOF (v7.0.1): a throw inside the health tick is an instance crash. Never.
      try { this.updateStatusAggregate() } catch {}
      try { this.updateUi() } catch {}
      try { this.maybeAutoResync() } catch {}
      try { this._videoTick() } catch {}
      try { this._maybeRefreshLicense() } catch {}
      try { this._recomputeLicense() } catch {}   // 0.12.52: refresh the cached revoke/seat hard-stop verdict (fail-open)
      try { this._updateTimerVars() } catch {}
      try { if ((this._cfgTick % 4) === 0) this._refreshLeaderCfg() } catch {}      // 0.12.49.1: mirror the leader's look/notes config (~4s)
      try { if ((++this._cfgTick % 4) === 2) this._refreshCues() } catch {}          // 0.12.49.2: mirror the leader's cue list (~4s, offset from config)
      try { if ((++this._licPushTick % 30) === 0) this.pushLiveLicense() } catch {}   // 0.12.2: keep the fleet's live licence fresh (~30s)
    }, 1000)
    this.healthTimer.unref?.()
    // 0.12.49.1/.2: read the leader's look/notes config + cue list once shortly after connect so the toggles,
    // pickers, and feedbacks are live without waiting for the first tick.
    setTimeout(() => { try { this._refreshLeaderCfg() } catch {} try { this._refreshCues() } catch {} }, 1500)

    // 0.12.15: a SEPARATE 4Hz loop, dedicated to the timer edges that drive hardware. The 1Hz health tick is
    // right for staleness and licence housekeeping, but 1s of slop on a "we just hit zero" edge is visible on a
    // chase light and unacceptable on a GPI. This tick does almost nothing when idle (a cheap kuetimer() read
    // and a couple of comparisons) and is fully crash-guarded, so it is safe to run four times a second.
    this.timerEdgeTimer = setInterval(() => { try { this._timerEdgeTick() } catch {} }, 250)
    this.timerEdgeTimer.unref?.()
  }

  // Is the timer we're reading actually LIVE, or are we extrapolating a frozen clock off a dropped leader?
  // kuetimer() rebases remaining by wall-time on every call, so a leader that goes silent keeps "counting down"
  // locally and would sail a stale clock through warn and zero. present === "we once received a timer"; live ===
  // "the source is connected AND still heart-beating within the stale window". Hardware edges MUST gate on live,
  // never on present — firing a GPI/ATEM/lighting cue off a dead connection is exactly the failure to avoid.
  _timerLive() { const p = this.primary(); return !!(p && p.timer && this.healthy(p)) }

  // Detect threshold CROSSINGS and republish the edge counters + the fast timer feedbacks. Called at 4Hz.
  _timerEdgeTick() {
    const k = this.kuetimer()
    // On a stale/absent leader, do NOT advance edges and clear the cue identity vars — but still re-check the
    // level feedbacks so any lit lamp/relay releases rather than latching on a frozen clock. Reset the level
    // memory so a reconnect re-arms cleanly (the next real crossing fires fresh).
    if (!k.present || !this._timerLive()) {
      this._wasOvertime = false; this._wasWarn = false; this._vidWasOver = false
      this.setVariableValues({ cue_zero_seq: this._zeroSeq, cue_warn_seq: this._warnSeq, cue_id: '', cue_take_at: '',
        video_remaining: '—', video_remaining_secs: 0, video_source: '—', video_active: 'false', video_state: 'idle', video_out_seq: this._vidSeq || 0 })
      this._takeAtCueId = ''; this._cueTakeAt = 0
      try { this.checkFeedbacks('cue_timer_zero', 'cue_timer_warn', 'cue_time_remaining_below', 'cue_running_behind', 'video_active', 'video_ending_below') } catch {}
      return
    }

    // Fire on the TRANSITION into a state, not while it holds — that is what makes these usable as hardware
    // edges. A countdown is monotonic, so each of these fires exactly once per run; a re-take (new cueId, fresh
    // rev) naturally re-arms because the level drops back out of the window first.
    const overtime = k.state === 'overtime'
    // The warn edge counts the whole warn-and-past region (so a big step straight past the warn band into
    // overtime still fires it) — but ONLY when a warn threshold actually exists. With warn disabled (warnMs=0)
    // there is no warn point to cross, so firing a warn edge at 0:00 would be a phantom that duplicates the
    // zero edge.
    const warn = (k.warnMs > 0) && (k.state === 'warning' || overtime)
    if (overtime && !this._wasOvertime) this._zeroSeq++
    if (warn && !this._wasWarn) this._warnSeq++
    this._wasOvertime = overtime
    this._wasWarn = warn

    // take_at: the wall-clock this cue went on air, observed locally when the cueId first changed. Published as
    // a clock string so a trigger/hardware log can stamp "cue X took at 14:03:07".
    //
    // A cue change also RE-ARMS the edge memory. Without this, taking an already-expired cue while the previous
    // one is still in overtime would leave state==='overtime' unbroken, so the zero edge never re-fires and the
    // second hard-out is missed. Resetting on the new cueId lets the very next tick see the crossing fresh: a
    // re-take of an expired cue pulses zero once, and a re-take of a cue with time on the clock does not (its
    // overtime is false), so there is no spurious edge either way.
    if (k.cueId && k.cueId !== this._takeAtCueId) {
      this._takeAtCueId = k.cueId; this._cueTakeAt = Date.now()
      this._wasOvertime = false; this._wasWarn = false
    }

    // 0.12.16 VIDEO COUNT-OUT vars + edge. The agent-fanned reading is honesty-gated already (show:false when
    // stale/absent), so we only publish live values and blank them otherwise. video_out_seq ticks ONCE on the
    // transition into 0:00, re-arming whenever the count leaves over — the same edge discipline as the cue zero.
    const vid = (typeof this.ksVideo === 'function') ? this.ksVideo() : { show: false }
    const vOver = !!(vid.show && vid.state === 'over')
    if (vOver && !this._vidWasOver) this._vidSeq = (this._vidSeq || 0) + 1
    this._vidWasOver = vOver

    this.setVariableValues({
      cue_zero_seq: this._zeroSeq,
      cue_warn_seq: this._warnSeq,
      cue_id: k.cueId || '',
      cue_take_at: this._cueTakeAt ? this._clockStr(this._cueTakeAt) : '',
      video_remaining: vid.show ? String(vid.text || '') : '—',
      video_remaining_secs: vid.show ? Math.round((Number(vid.remainingMs) || 0) / 1000) : 0,
      video_source: vid.show ? String(vid.label || '') : '—',
      video_active: String(!!vid.show),
      video_state: vid.show ? String(vid.state || '') : 'idle',
      video_out_seq: this._vidSeq || 0,
    })
    // Re-evaluate the level feedbacks at 4Hz so cue_time_remaining_below and the zero/warn lamps catch a
    // crossing within 250ms instead of within a second.
    this.checkFeedbacks('cue_timer_zero', 'cue_timer_warn', 'cue_time_remaining_below', 'cue_running_behind',
      'video_active', 'video_ending_below')
  }

  // HH:MM:SS in the operator's local wall clock, for stamping variables.
  _clockStr(ms) {
    const p = (n) => (n < 10 ? '0' : '') + n
    const d = new Date(ms || Date.now())
    return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
  }

  // 0.10.0: SEAMLESS RENEWAL for the COMPANION-DRIVEN topology (Companion + Followers, no agent Leader).
  // When THIS connection holds the paid key (entered in the module's Licensing field) and it is within its
  // renewal window / just expired, quietly fetch the NEWEST key issued to the same account, verify it locally
  // against the bundled public key, and install it ONLY IF strictly newer. The module gates command-sending on
  // entitlement (it doesn't push keys to agents), so installing locally is all that's needed. If a primary
  // AGENT holds the key instead (its own field empty here), this no-ops and the agent handles its own renewal.
  // Best-effort, throttled, never throws; trial / keyless setups never call out.
  _maybeRefreshLicense() {
    const key = this.config.licenseKey || ''
    if (!key || this._licRefreshing) return
    const now = this.license.now()
    const cur = decodeKeyPayload(key)
    const decision = shouldRefresh({ haveKey: true, expiry: cur && cur.expiry, now, lastTryAt: this._licRefreshAt || 0 })
    // 0.12.52: besides the renewal-window PULL, run a PERIODIC (jittered 6–12h; ~3m fast-retry while offline)
    // check-in so a server-side REVOKE and the SEAT verdict are caught even for a healthy key far from expiry —
    // exactly like the agent. Same network call, same fail-open handling (unreachable ⇒ no change).
    const dueMs = (this._licCheckInReachable === false) ? this._checkInRetryMs : this._checkInIntervalMs
    const periodicDue = (now - (this._licCheckInAt || 0)) >= dueMs
    if (!decision.go && !periodicDue) return
    this._licRefreshAt = now
    if (periodicDue) this._licCheckInAt = now
    this._licRefreshing = true
    const endpoint = (this.config.licenseRefreshUrl && /^https:\/\//i.test(this.config.licenseRefreshUrl)) ? this.config.licenseRefreshUrl : DEFAULT_REFRESH_ENDPOINT
    // Attach the machine's SIGNED identity + fleet/role so the server can activate + count seats. All optional and
    // best-effort: no identity (or a signing failure) ⇒ a keyId-less check-in the server treats as legacy (never
    // seat-denied). role='companion' — a Companion holding its own key licenses one fleet = one seat.
    let ident = {}
    try {
      if (this.identity) {
        const fleetId = this._fleetId() || ''
        const role = 'companion'
        const ts = now
        const keyId = this.identity.keyId || ''
        const pub = this.identity.publicKeyPem || ''
        let sig = ''
        try { sig = this.identity.sign(`kuesync-activate\n${keyId}\n${fleetId}\n${role}\n${ts}`) } catch {}
        ident = { keyId, pub, fleetId, role, ts, sig }
      }
    } catch {}
    Promise.resolve(this._fetchRefresh({ endpoint, currentKey: key, email: (cur && cur.email) || '', ...ident }))
      .then((res) => {
        this._licRefreshing = false
        // Tolerate both shapes: the 0.11.4+ verdict object AND a bare key string (legacy transport / injected test).
        const v = (typeof res === 'string') ? { key: res, revoked: false, seat: undefined, reachable: true } : (res || {})
        this._licCheckInReachable = !!v.reachable
        // FAIL-OPEN: an unreachable server NEVER changes entitlement — keep running on the locally-verified key.
        if (v.reachable) {
          const patch = {}
          if (v.revoked) patch.revoked = true
          else if (this._licState.revoked) patch.revoked = false        // server re-instated a previously-revoked key
          if (v.seat === 'ok') { patch.activatedAt = now; patch.seatDenied = false; patch.seatSuspectedAt = 0 }
          else if (v.seat === 'denied') patch.seatDenied = true
          if (Object.keys(patch).length) { Object.assign(this._licState, patch); try { persistLicState(patch) } catch {} }
          if (v.revoked) this.log('warn', 'license check-in: server returned REVOKED — enforcement will confirm')
          else if (v.seat === 'denied') this.log('warn', 'license check-in: seat over the plan limit — activate another seat at kuesync.com')
          else this.log('info', 'license check-in: ok')
        } else {
          this.log('info', 'license check-in: unreachable (offline) — keeping current entitlement')
        }
        // Renewal PULL: install a strictly-newer signed key for the same account, if the server returned one.
        const newKey = v.key
        if (newKey) {
          const verdict = acceptRefreshedKey(newKey, cur, this.license.now())
          if (!verdict.ok) { this.log('info', `license refresh: keeping current key (${verdict.reason})`) }
          else {
            this.license.setKey(newKey)
            this.config.licenseKey = newKey
            try { this.saveConfig(this.config) } catch {}
            this._noteKeyFirstRun(newKey)                 // a renewed key restarts its own activation clock
            const thru = verdict.payload && verdict.payload.expiry ? new Date(verdict.payload.expiry).toISOString().slice(0, 10) : 'perpetual'
            this.log('info', `license auto-renewed (valid through ${thru})`)
          }
        }
        try { this._recomputeLicense() } catch {}
        try { this.refreshLicenseUi() } catch {}
      })
      .catch(() => { this._licRefreshing = false; this._licCheckInReachable = false })
  }

  // 0.12.52: stamp the first-run time for the CURRENTLY installed key (starts the seat/activation clock), and
  // reset the per-key activation state when a genuinely different key is installed — so a new key gets a fresh
  // bootstrap window and never inherits a stale revoke/seat-denied from a previous key. Keyed by a stable
  // fingerprint of the signed payload (customer|issued|expiry|seats). No valid key ⇒ clear the activation clock.
  _noteKeyFirstRun(key) {
    try {
      const st = this._licState || (this._licState = loadLicState())
      const now = this.license ? this.license.now() : Date.now()
      const vr = verifyLicenseKey(key || '', now)
      if (!vr || !vr.valid) {
        if (st.firstRunAt || st.keyFp) { const patch = { firstRunAt: 0, keyFp: '' }; Object.assign(st, patch); try { persistLicState(patch) } catch {} }
        return
      }
      const p = vr.payload || {}
      const fp = `${p.customer || ''}|${p.issued || ''}|${p.expiry || ''}|${p.seats == null ? 'legacy' : p.seats}`
      if (st.keyFp !== fp) {
        const patch = { keyFp: fp, firstRunAt: now, activatedAt: 0, seatDenied: false, seatSuspectedAt: 0, revoked: false, suspectedAt: 0 }
        Object.assign(st, patch)
        try { persistLicState(patch) } catch {}
      } else if (!st.firstRunAt) {
        const patch = { firstRunAt: now }; Object.assign(st, patch); try { persistLicState(patch) } catch {}
      }
    } catch { /* fail-open: never block init on a persistence error */ }
  }

  // 0.12.52: recompute the CACHED hard-stop verdict off the command path (called on the health tick + after a
  // check-in). PURE evaluators, FAIL-OPEN. Only a CONFIRMED verdict (server-affirmed revoke, or the ≤24h seat /
  // activation ladder fully elapsed, or the expiry ladder fully elapsed) sets _enfBlocked → the Companion's own
  // key stops licensing the fleet. SUSPECTED / WATERMARK stay fully functional (a control surface never cuts a
  // running cue). Advancing state (high-water + the two suspicion clocks) is persisted so a restart can't reset a lapse.
  _recomputeLicense() {
    try {
      const key = this.config.licenseKey || ''
      const now = this.license.now()
      const st = this._licState || (this._licState = loadLicState())
      const vr = verifyLicenseKey(key, now)
      const hasValidKey = !!(vr && vr.valid)
      const isLegacy = hasValidKey && (vr.payload.seats == null)
      const enf = evaluateEnforcement({ key, trialStart: this.license.trialStart, suspectedAt: st.suspectedAt, highWater: st.highWater, revoked: st.revoked, now })
      const seat = evaluateSeatEnforcement({ hasValidKey, isLegacy, firstRunAt: st.firstRunAt, activatedAt: st.activatedAt, seatDenied: st.seatDenied, seatSuspectedAt: st.seatSuspectedAt, highWater: enf.highWater, now })
      const patch = {}
      const hw = Math.max(Number(enf.highWater) || 0, Number(seat.highWater) || 0)
      if (hw && hw !== st.highWater) { patch.highWater = hw; st.highWater = hw }
      if (enf.suspectedAt !== st.suspectedAt) { patch.suspectedAt = enf.suspectedAt; st.suspectedAt = enf.suspectedAt }
      if (seat.seatSuspectedAt !== st.seatSuspectedAt) { patch.seatSuspectedAt = seat.seatSuspectedAt; st.seatSuspectedAt = seat.seatSuspectedAt }
      if (Object.keys(patch).length) { try { persistLicState(patch) } catch {} }
      this._enfBlocked = (enf.enforce === ENFORCE.CONFIRMED) || (seat.enforce === ENFORCE.CONFIRMED)
      this._enfReason = this._enfBlocked ? (enf.enforce === ENFORCE.CONFIRMED ? enf.reason : seat.reason) : ''
    } catch { this._enfBlocked = false }   // fail-open — a paying show is never blocked by an evaluator bug
  }

  async destroy() {
    this.cancelCue()
    this.closeAll()
    if (this._seenTimer) clearInterval(this._seenTimer)
    if (this.healthTimer) clearInterval(this.healthTimer)
    if (this.timerEdgeTimer) clearInterval(this.timerEdgeTimer)
    if (this.maxSeen) persistLastSeen(this.maxSeen)
  }

  async configUpdated(config) {
    this.config = config
    this.license.setKey(config.licenseKey || '')
    try { this._noteKeyFirstRun(config.licenseKey || '') } catch {}   // 0.12.52: a pasted/cleared key restarts its activation clock
    try { this._recomputeLicense() } catch {}
    this.refreshLicenseUi()
    this.connectAll()
  }

  // 0.9.8: choices for the Follower Slot dropdowns on the config page. Lists every machine this
  // connection already knows about — LEADER-canonical (its #1, #2… with device names) when a Leader is
  // present, otherwise the machines this connection is talking to — so you can ASSIGN/SHIFT which machine
  // sits in each slot from a dropdown instead of retyping IPs. allowCustom (set on the field) still lets
  // you type any IP / host:port on first setup or a Companion-only rig where nothing is known yet.
  configSlotChoices() {
    // 0.12.0: only offer machines actually reachable on the CURRENT network (a connected target, or one the
    // live Leader lists). Configured-but-unreachable IPs left over from a PRIOR network are dropped — they were
    // cluttering the dropdown with stale addresses. Agent LEADERS are surfaced explicitly and listed first.
    const out = [{ id: '', label: '— empty —' }]
    const seen = new Set()
    const add = (ip, label) => { const k = String(ip).toLowerCase(); if (!ip || seen.has(k)) return; seen.add(k); out.push({ id: String(ip), label }) }
    const lead = this.leaderTarget && this.leaderTarget()
    // 1) the active Leader(s) this connection currently sees — pickable as a slot, listed first.
    for (const t of (this.targets || [])) {
      if (!t.connected) continue
      if (t.state && t.state.role === 'leader') {
        const ip = String(t.baseHost || t.host)
        add(ip, `Leader${t.state.label ? ' · ' + t.state.label : ''} · ${ip}`)
      }
    }
    // 2) the Leader's canonical follower fleet (current network, authoritative numbering).
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      for (const f of lead.state.fleetFollowers) {
        const ip = String(f.ip)
        const t = (this.targets || []).find((x) => this.normHost(x.baseHost || x.host) === this.normHost(ip))
        const name = t && t.state && t.state.label ? ' · ' + t.state.label : ''
        add(ip, `Follower ${f.n}${name} · ${ip}`)
      }
    }
    // 3) any other machine actually CONNECTED here (stale prior-network IPs never connect → excluded).
    for (const t of (this.targets || [])) {
      if (!t.connected) continue
      const ip = String(t.baseHost || t.host)
      const name = t.state && t.state.label ? ' · ' + t.state.label : ''
      add(ip, `${ip}${name}`)
    }
    return out
  }

  // A live, human-readable snapshot of what THIS connection currently sees on the network, for the config
  // panel. Companion only calls getConfigFields() when the panel opens (it doesn't live-refresh form fields),
  // so the Slot dropdowns can look stale on first open before the fleet has reported. This text reflects the
  // module's state at the moment the panel is (re)opened — re-open Settings, or hit any field, to refresh it.
  seenMachinesHtml() {
    const lead = this.leaderTarget && this.leaderTarget()
    // Map normalized IP → stable follower number from the Leader's canonical list (entries are { n, ip }).
    const numByIp = new Map()
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      for (const f of lead.state.fleetFollowers) numByIp.set(this.normHost(String(f.ip)), f.n)
    }
    const rows = (this.targets || []).map((t) => {
      const host = t.baseHost || t.host
      const name = (t.state && t.state.label) ? t.state.label : ''
      const dot = this.healthy(t) ? '🟢' : (t.connected ? '🟠' : '⚪')
      const n = numByIp.get(this.normHost(host))
      const fnum = n ? `#${n} · ` : ''
      return `${dot} ${fnum}${name ? '<b>' + name + '</b> · ' : ''}${host}`
    })
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      // The Leader knows the canonical fleet — surface any member this connection hasn't dialled yet so the
      // operator sees the real machine numbers/IPs without guessing. (Names arrive once it's connected here.)
      const known = new Set((this.targets || []).map((t) => this.normHost(t.baseHost || t.host)))
      for (const f of lead.state.fleetFollowers) {
        if (known.has(this.normHost(String(f.ip)))) continue
        rows.push(`⚪ #${f.n} · ${f.ip} <i>(seen by Leader, not yet connected here)</i>`)
      }
    }
    return rows.length ? rows.join('<br/>') : '<i>No machines seen yet. Open KueSync on a presentation machine and enter its IP in a slot below (it will fill in its name once connected).</i>'
  }

  getConfigFields() {
    // 0.9.8: each machine slot is a dropdown of known machines (+ allowCustom to type any IP), so a
    // Follower Slot can be re-assigned/shifted by name/IP and stays consistent with the Leader's numbering.
    const slotChoices = this.configSlotChoices()
    const slot = (i) => ({
      type: 'dropdown', id: 'ip' + i, width: 6,
      label: `Follower Slot ${i}${i === 1 ? ' — machine / IP' : ''}`,
      default: i === 1 ? '127.0.0.1' : '',
      choices: slotChoices, allowCustom: true,
      tooltip: 'Pick a machine KueSync already sees, or type any IP (host or host:port). This is Follower ' + i + ' — the same position used on the Companion follower buttons, so assignments stay consistent with the Leader’s numbering.',
    })
    return [
      { type: 'static-text', id: 'about', width: 12, label: 'About', value: '<b>KueSync</b> &mdash; multi-machine slide sync. © 2026 Krometis Analytics. All rights reserved.<br/><a href="https://kuesync.com" target="_blank">kuesync.com</a> · support@kuesync.com' },
      { type: 'static-text', id: 'info', width: 12, label: 'KueSync — multi-machine slide sync', value: 'Assign one presentation machine to each <b>Follower Slot</b> below (Mac or Windows PC). Pick a machine KueSync already sees from the dropdown, or type its IP — find a machine’s IP in its KueSync menu-bar / tray menu. Leave unused slots empty. The slot number is the Follower position used on the Companion buttons, so it matches the Leader’s numbering. Every button drives all connected machines in sync.' },
      { type: 'static-text', id: 'seen', width: 12, label: 'Currently seen on the network (live snapshot — re-open Settings to refresh)', value: '🟢 healthy · 🟠 connecting · ⚪ known but not connected here<br/>' + this.seenMachinesHtml() },
      slot(1), slot(2), slot(3), slot(4), slot(5), slot(6), slot(7), slot(8),
      { type: 'textinput', id: 'prefSubnet', width: 12, label: 'Preferred network for traffic (optional) — e.g. 172.26.32', default: '', tooltip: 'On a multi-network rig (a dedicated control LAN + house Wi-Fi), enter the first three octets of the subnet you want KueSync traffic to ride. The module asks each agent for all its addresses and connects over the one on this subnet — so you can type any reachable IP above and still keep the show on your control network. Agents keep listening & discovering on every interface. Blank = use the IPs exactly as typed.' },
      { type: 'number', id: 'port', width: 4, label: 'Default port', default: DEFAULT_PORT, min: 1, max: 65535 },
      { type: 'number', id: 'reconnect', width: 4, label: 'Reconnect (ms)', default: 3000, min: 500, max: 60000 },
      { type: 'number', id: 'syncWindow', width: 4, label: 'Sync tightening (ms)', default: 120, min: 0, max: 1000, tooltip: 'A small buffer so every screen flips together. The default is dialed in for rock-solid, snappy sync — leave it unless you have a reason to change it. 0 = single machine.' },
      { type: 'dropdown', id: 'syncMode', width: 8, label: 'Sync timing', default: 'tod', choices: [
        { id: 'tod', label: 'Standard (tightest — per-machine clock correction, default)' },
        { id: 'relative', label: 'Relative delay (fallback — each machine fires a fixed delay after receipt)' },
      ], tooltip: 'Leave on Standard. KueSync continuously measures each machine’s clock and corrects every cue per machine, so the fleet fires together even on closed networks. Relative is a compatibility fallback.' },
      { type: 'checkbox', id: 'resyncOnReconnect', width: 4, label: 'Resync a machine when it reconnects', default: true, tooltip: 'If a machine drops and rejoins mid-show it has missed advances; snap just that machine to the fleet’s current slide so it’s realigned (skips its in-slide builds).' },
      { type: 'number', id: 'staleSeconds', width: 4, label: 'Health timeout (s)', default: 6, min: 3, max: 60, tooltip: 'A machine is shown HEALTHY only if it reported within this many seconds. Agents heartbeat every ~2s, so 6 tolerates a couple of misses. A connected-but-silent (wedged) machine turns red.' },
      { type: 'checkbox', id: 'autoResync', width: 4, label: 'Auto-resync drift (skips builds!)', default: false, tooltip: 'If the fleet stays out of sync past the settle window, snap everyone to the primary’s slide. OFF by default because snapping skips in-slide builds — best for video walls / static decks.' },
      { type: 'number', id: 'autoResyncSeconds', width: 4, label: 'Auto-resync after (s)', default: 4, min: 1, max: 60, tooltip: 'How long drift must persist (beyond the settle window) before an auto-resync snap.' },
      { type: 'textinput', id: 'token', width: 12, label: 'Fleet Password (optional — must match the one set on the agents)', default: '', tooltip: 'On a shared or house network, set the same Fleet Password on every agent and here, so only your control surfaces can drive the show. Leave blank on an isolated show network.' },
      { type: 'textinput', id: 'cueSheet', width: 12, label: 'Fallback cue sheet (optional; buttons can set their own timing)', default: 'default=10' },
      { type: 'dropdown', id: 'cueMode', width: 6, label: 'Cue light mode', default: 'flash', choices: [
        { id: 'flash', label: 'Flash (pulse on each move — like a hardware cue light)' },
        { id: 'latch', label: 'Latch (hold the last direction until the next move)' },
      ], tooltip: 'Drives the Cue Light presets: GREEN flashes on a forward advance, RED on a backward one, mirroring the presenter’s clicker. Tile the lamp across several keys for a big cue light.' },
      { type: 'number', id: 'cueHoldMs', width: 6, label: 'Cue flash hold (ms)', default: 800, min: 120, max: 5000, tooltip: 'How long the green/red lamp stays lit after each move in Flash mode.' },
      { type: 'static-text', id: 'videoHelp', width: 12, label: 'Video time (TRT / remaining)', value: 'On <b>Windows + PowerPoint</b> KueSync reads each clip’s real length automatically (the variable shows <b>measured</b>). On <b>macOS / Keynote the OS does not expose video length</b>, so it can’t be measured — enter the clip length per slide in the cue sheet above (e.g. <code>7=120</code> = a 2:00 video on slide 7) and the variable shows <b>planned (cue sheet)</b>. With nothing entered it shows <b>not available</b>. Add the “Video — …” buttons from the presets.' },
      { type: 'dropdown', id: 'videoSource', width: 6, label: 'Video count-out source (0.12.17)', default: 'auto', choices: [
        { id: 'auto', label: 'Auto (external if live, else embedded)' },
        { id: 'embedded', label: 'Embedded deck clip' },
        { id: 'mitti', label: 'Mitti (OSC)' },
        { id: 'playbackpro', label: 'PlaybackPro' },
        { id: 'external', label: 'External (generic relay)' },
        { id: 'off', label: 'Off' },
      ], tooltip: 'Which count-out the video_* variables and feedbacks read. The leader fans every source; this pins the one Companion shows. Only appears while that source is live and accurate.' },
      { type: 'static-text', id: 'lic', width: 12, label: 'Licensing', value: '7-day free trial. Enter a license key here <b>or</b> on a leader agent (KueSync ▸ Settings) — either licenses the whole fleet. Existing keys keep working.' },
      { type: 'textinput', id: 'licenseKey', width: 12, label: 'License key (optional if a leader agent is licensed)', default: '' },
    ]
  }

  // ----------------------------- targets / connections -----------------------------
  parseAgents() {
    const def = Number(this.config.port) || DEFAULT_PORT
    // Collect from the per-machine IP fields (ip1..ip8), plus any legacy multi-line "agents" field.
    const entries = []
    for (let i = 1; i <= 8; i++) {
      const v = this.config['ip' + i]
      if (v && String(v).trim()) entries.push(v)
    }
    if (this.config.agents) entries.push(this.config.agents)

    const out = []
    const seen = new Set()
    for (const entry of entries) {
      for (const raw of String(entry).split(/[\n,;]+/)) {
        const s = raw.trim()
        if (!s) continue
        const { host, port } = splitHostPort(s, def)
        if (!host) continue
        const target = { host, port }
        const key = `${target.host}:${target.port}`
        if (seen.has(key)) continue
        seen.add(key)
        out.push(target)
      }
    }
    if (!out.length) out.push({ host: '127.0.0.1', port: def })
    return out
  }

  connectAll() {
    this.closeAll()
    this.targets = this.parseAgents().map((a) => ({
      ...a, baseHost: a.host, ws: null, connected: false, lastStateAt: 0,
      clockOffset: 0, pongOffset: null, bestRtt: null,   // per-agent clock sounding (see _soundClocks)
      state: { app: '', playing: false, slide: 0, total: 0, slideShown: 0, totalShown: 0, buildIndex: -1, buildCount: -1, role: '', leaderHost: '', mediaTrtMs: -1, mediaTrigger: '', mediaPlayClick: -1, followerActive: true, slideDriver: false, fleetFollowers: [] }, reconnectTimer: null,
    }))
    this.targets.forEach((t) => this.routeAndConnect(t))
    // Security heads-up (C1): without a shared token, control is open to anyone who can reach the
    // agents. Loopback / link-local rigs are inherently scoped; warn when targets are routable.
    const routable = this.targets.some((t) => !/^(127\.|169\.254\.|localhost$)/i.test(String(t.baseHost)))
    if (routable && !String(this.config.token || '').trim()) {
      this.log('warn', 'No Fleet Password set while agents are on a routable network — anyone on that LAN can drive the show. Set a matching Fleet Password on the agents and in this connection to lock control down.')
    }
    this.updateStatusAggregate()
    this.updateUi()
  }

  // Preferred-subnet routing: if a preferred network is set, ask the agent (at its typed address) for
  // ALL its addresses and connect over the one on that subnet — so traffic rides the chosen control LAN
  // even if a different IP was typed. Agents still listen/discover on every interface. Blank = as typed.
  routeAndConnect(t) {
    const pref = String(this.config.prefSubnet || '').trim().replace(/\.$/, '')
    if (!pref) { t.host = t.baseHost; return this.connectTarget(t) }
    // Match on an OCTET BOUNDARY — "10.1.1" must match 10.1.1.x but NOT 10.1.10.x / 10.1.100.x
    // (a bare prefix match could pin show traffic onto the wrong subnet).
    this.resolveAddresses(t.baseHost, t.port, (ips) => {
      const match = (ips || []).find((a) => String(a) === pref || String(a).startsWith(pref + '.'))
      t.host = (match && match !== t.baseHost) ? match : t.baseHost
      if (match && match !== t.baseHost) this.log('info', `route ${t.baseHost} → ${t.host} (preferred subnet ${pref})`)
      this.connectTarget(t)
    })
  }

  // Fetch the agent's /health and return its advertised LAN addresses (best-first). Short timeout; any
  // failure → null so we fall back to the typed address.
  resolveAddresses(host, port, cb) {
    let done = false
    const finish = (ips) => { if (!done) { done = true; cb(ips) } }
    try {
      const req = http.get({ host, port, path: '/health', timeout: 1500 }, (res) => {
        let body = ''
        res.on('data', (d) => { body += d })
        res.on('end', () => { try { const j = JSON.parse(body); finish((j.ips || []).map((x) => (x && x.address) ? x.address : x)) } catch { finish(null) } })
      })
      req.on('error', () => finish(null))
      req.on('timeout', () => { try { req.destroy() } catch {} ; finish(null) })
    } catch { finish(null) }
  }

  connectTarget(t) {
    const url = `ws://${t.host}:${t.port}/`
    // Token travels in a header, not the query string (keeps it out of URLs / logs).
    const opts = this.config.token ? { headers: { 'x-kuesync-token': String(this.config.token) } } : {}
    let ws
    opts.maxPayload = 256 * 1024   // cap inbound frames from an agent (defense-in-depth, W-S4)
    try { ws = new WebSocket(url, opts) } catch (e) { this.log('error', `WS init ${t.host}: ${e.message}`); return this.scheduleReconnect(t) }
    t.ws = ws
    ws.on('open', () => { try {
      try { ws._socket.setNoDelay(true) } catch {}   // commands must not sit in Nagle's buffer
      const wasConnectedBefore = t.everConnected
      t.everConnected = true
      t.connected = true; t.lastStateAt = Date.now(); t.authRejected = false   // fresh connection → clear any prior password-mismatch flag
      this.log('info', `connected ${t.host}:${t.port}`)
      try { this.pushLiveLicense() } catch {}   // 0.12.2: if THIS Companion is licensed, license this agent for the session
      // Reconnect realignment: a machine that dropped and rejoined mid-show missed advances. Snap
      // ONLY it to the fleet's current slide (not the whole fleet), so it catches up without
      // disturbing the others. Skips its builds — a slide-boundary snap is the honest recovery.
      if (wasConnectedBefore && this.config.resyncOnReconnect !== false) {
        const n = this.intendedSlide || this.primarySlide()
        if (n > 0) {
          this.log('info', `resync ${t.host} after reconnect → slide ${n}`)
          setTimeout(() => this.sendToTarget(t, { action: 'goto', slide: n }), 150)
        }
      }
      this.updateStatusAggregate(); this.updateUi()
      // 0.10.29: re-evaluate ALL feedbacks once the agent connection is live. Companion may not track a feedback
      // added before the instance was Ok, which is the remaining cause of "the cue light needs a remove + re-add."
      // A bare checkFeedbacks() forces a fresh evaluation of every button (cue light included) on (re)connect.
      // 0.12.0: also re-SUBSCRIBE all feedbacks ONCE after the first live connect — the durable fix for the lamp
      // going dead after a module reload (it re-binds existing button instances to this instance, like a re-add).
      if (!this._didResubscribe) { this._didResubscribe = true; try { this.subscribeFeedbacks() } catch {} }
      try { this.checkFeedbacks() } catch {}
    } catch (e) { this.log('debug', `open handling error ${t.host}: ${e.message}`) } })
    ws.on('message', (data) => this.onTargetMessage(t, data))
    // CRASH-PROOF (v7.0.2): close fires every ~3s for an ABSENT machine — a throw here crash-looped
    // the whole instance (red, config dead, lamps dead). Reconnect is scheduled NO MATTER WHAT.
    ws.on('close', () => {
      try { t.connected = false; this._stopSounding(t); this.updateStatusAggregate(); this.updateUi() }
      catch (e) { this.log('debug', `close handling error ${t.host}: ${e.message}`) }
      finally { this.scheduleReconnect(t) }
    })
    ws.on('error', (e) => this.log('debug', `WS error ${t.host}: ${e.message}`))
    this._startSounding(t)
  }

  // ---- per-agent clock sounding (round trip — same math as the Leader link) ----
  // Each machine's clock offset is measured continuously (offset = tf − (t0+t1)/2, unbiased under
  // symmetric latency) and every cue's fire time is translated into THAT machine's clock, so the
  // fleet fires at the same physical instant even on closed networks with no NTP. Agents that
  // predate the sounding simply never answer — they get uncorrected times, exactly as before.
  _startSounding(t) {
    this._stopSounding(t)
    const ping = () => { if (t.ws && t.ws.readyState === WebSocket.OPEN) { try { t.ws.send(JSON.stringify({ type: 'ks.tping', t0: Date.now() })) } catch {} } }
    t.pingTimer = setInterval(ping, 2000)
    setTimeout(ping, 200)   // first sample right after connect (after the agent wires its handlers)
  }
  _stopSounding(t) { if (t.pingTimer) { clearInterval(t.pingTimer); t.pingTimer = null } }

  scheduleReconnect(t) {
    if (t.reconnectTimer) return
    // Re-resolve the preferred-subnet address on each reconnect so a DHCP/address change recovers.
    t.reconnectTimer = setTimeout(() => {
      t.reconnectTimer = null
      try { this.routeAndConnect(t) } catch (e) { this.log('debug', `reconnect error ${t.host}: ${e.message}`); this.scheduleReconnect(t) }
    }, this.config.reconnect || 3000)
  }

  closeAll() {
    for (const t of this.targets) {
      if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null }
      this._stopSounding(t)
      this._closeSocket(t)
    }
    this.targets = []
  }

  // CRASH-PROOF teardown (0.12.0): closing a WebSocket that is still CONNECTING makes the `ws` library emit
  // an ASYNCHRONOUS 'error' event ("WebSocket was closed before the connection was established"). The old
  // code did removeAllListeners() THEN close(), so that async error had NO listener → an unhandled 'error'
  // event crashed the WHOLE module process, and Companion restarted it in a loop (it never connected). The
  // try/catch did nothing because the throw happens on a later tick, not inside close(). Fix: strip the real
  // handlers, attach a NO-OP 'error' sink so the async error is swallowed, then terminate() (immediate, safe
  // whether the socket is CONNECTING or OPEN). Belt-and-suspenders: never let teardown throw.
  _closeSocket(t) {
    if (!t || !t.ws) return
    const ws = t.ws
    t.ws = null
    try { ws.removeAllListeners() } catch {}
    try { ws.on('error', () => {}) } catch {}   // swallow the connecting-close 'error' so it can't go unhandled
    try { ws.terminate ? ws.terminate() : ws.close() } catch {}
  }

  onTargetMessage(t, data) {
    // CRASH-PROOF (v7.0.1): an exception anywhere in message handling used to take down the ENTIRE
    // Companion connection (instance crash -> red, config unusable) when one agent died mid-stream.
    // Losing one machine must only ever cost that machine: yellow status, everything else drives on.
    try { this._onTargetMessage(t, data) } catch (e) { this.log('debug', `message handling error from ${t.host}: ${e.message}`) }
  }
  _onTargetMessage(t, data) {
    let msg
    try { msg = JSON.parse(data.toString()) } catch { return }
    t.lastStateAt = Date.now()   // any message = proof of life (used for staleness / health)
    if (msg.type === 'ks.tpong') {
      // Round-trip clock sample: offset = tf − (t0+t1)/2. Only accept clean (near-best-RTT) samples
      // so a congested moment can't poison the estimate; smooth with an EMA.
      const t1 = Date.now()
      if (typeof msg.t0 === 'number' && typeof msg.tf === 'number') {
        const rtt = t1 - msg.t0
        if (rtt >= 0 && rtt < 2000) {
          if (t.bestRtt == null || rtt < t.bestRtt) t.bestRtt = rtt
          if (rtt <= (t.bestRtt + 25)) {
            const raw = msg.tf - (msg.t0 + t1) / 2
            t.pongOffset = (t.pongOffset == null) ? raw : t.pongOffset * 0.7 + raw * 0.3
            t.clockOffset = Math.round(t.pongOffset)
          }
        }
      }
      return
    }
    if (msg.type === 'cue') {
      // Agent-pushed INSTANT cue (v6.4.7/8): every agent broadcasts on every executed advance —
      // the lamp fires on the FIRST event and re-lights on duplicates (never missed, never double-
      // counted; the state-poll observer is suppressed for 700ms after).
      this.cueLight.instAt = Date.now()
      this.log('debug', `cue ${msg.dir === 'back' ? 'back' : 'go'} (agent event)`)
      this._fireCue(msg.dir === 'back' ? 'back' : 'go')
      return
    }
    if (msg.type === 'timer') {
      // 0.10.2 KueSync Timer state pushed by an agent (the leader is authoritative; followers rebroadcast copies).
      // Store per-target with the receive time so kuetimer() can rebase remaining/elapsed onto NOW for display.
      t.timer = { mode: msg.mode || 'down', running: !!msg.running, remainingMs: +msg.remainingMs || 0, elapsedMs: +msg.elapsedMs || 0,
        warnMs: +msg.warnMs || 0, endAction: msg.endAction || 'blink', zeroColor: String(msg.zeroColor || 'red'), message: String(msg.message || ''),
        durationMs: +msg.durationMs || 0, rev: +msg.rev || 0, at: Date.now(),
        // 0.12.15 TIMER CUES. remainingMs/durationMs above are the DISPLAYED values (what the presenter sees);
        // these are the operator's truth. Companion is an OPERATOR surface, so it may expose both — but the
        // two are kept in separately-named variables so a button can never show the wrong one by accident.
        endMode: String(msg.endMode || 'clock'), warp: !!msg.warp, factor: +msg.factor || 1,
        realRemainingMs: (msg.realRemainingMs != null) ? +msg.realRemainingMs : (+msg.remainingMs || 0),
        realDurationMs: (msg.realDurationMs != null) ? +msg.realDurationMs : (+msg.durationMs || 0),
        outAt: +msg.outAt || 0, cueId: String(msg.cueId || ''),
        pgmIndex: (msg.pgmIndex != null) ? +msg.pgmIndex : -1, pvwIndex: (msg.pvwIndex != null) ? +msg.pvwIndex : -1,
        pgmCue: String(msg.pgmCue || ''), pvwCue: String(msg.pvwCue || ''), cueCount: +msg.cueCount || 0,
        cueDeltaMs: +msg.cueDeltaMs || 0 }
      if (t === this.primary()) this._updateTimerVars()
      return
    }
    if (msg.type === 'state') {
      t.state = {
        app: msg.app || t.state.app, playing: !!msg.playing, slide: msg.slide || 0, total: msg.total || 0,
        slideShown: msg.slideShown != null ? msg.slideShown : (msg.slide || 0),
        totalShown: msg.totalShown != null ? msg.totalShown : (msg.total || 0),
        buildIndex: msg.buildIndex != null ? msg.buildIndex : -1,   // real build/click pos (PowerPoint), -1 = unknown
        buildCount: msg.buildCount != null ? msg.buildCount : -1,
        // v6 Leader/Follower: agents may report their own role and the IP of the Leader they track.
        // Pre-v6 agents send neither, so these stay '' and "current Leader" reads as none (additive).
        role: msg.role != null ? String(msg.role) : (t.state.role || ''),
        leaderHost: (msg.leaderHost != null || msg.leader != null)
          ? String(msg.leaderHost != null ? msg.leaderHost : msg.leader)
          : (t.state.leaderHost || ''),
        // v6.0.8 video clock — clip length (ms) of the current slide's media + how it's triggered.
        // Measured on Windows/PowerPoint; -1/'' on macOS where the OS doesn't expose it (cue-sheet fallback).
        mediaTrtMs: msg.mediaTrtMs != null ? Number(msg.mediaTrtMs) : (t.state.mediaTrtMs ?? -1),
        mediaTrigger: msg.mediaTrigger != null ? String(msg.mediaTrigger) : (t.state.mediaTrigger || ''),
        mediaPlayClick: msg.mediaPlayClick != null ? Number(msg.mediaPlayClick) : (t.state.mediaPlayClick ?? -1),
        // v6.3 additive: friendly name, this agent's version, and a published-update notice.
        label: msg.label != null ? String(msg.label) : (t.state.label || ''),
        leaderName: msg.leaderName != null ? String(msg.leaderName) : (t.state.leaderName || ''),
        agentVersion: msg.agentVersion != null ? String(msg.agentVersion) : (t.state.agentVersion || ''),
        updateVersion: (msg.updateAvailable && msg.updateAvailable.version) ? String(msg.updateAvailable.version) : (msg.updateVersion ? String(msg.updateVersion) : (t.state.updateVersion || '')),
        firedLateMs: msg.firedLateMs != null ? Number(msg.firedLateMs) : (t.state.firedLateMs || 0),
        clockSource: msg.clockSource ? String(msg.clockSource) : (t.state.clockSource || ''),
        // 0.9.6: this machine's follower-Active state (default true if an older agent omits it), so the
        // Activate/Deactivate buttons can show Active/Inactive feedback per machine.
        followerActive: msg.followerActive != null ? !!msg.followerActive : (t.state.followerActive !== false),
        // 0.12.53: this machine's SLIDE-DRIVER state (a follower the Leader designated to drive the fleet with its
        // USB advancer / Perfect Cue / keystrokes) — for the module's Slide-driver action, feedback and variable.
        slideDriver: msg.slideDriver != null ? !!msg.slideDriver : (t.state.slideDriver === true),
        // 0.9.8: the Leader's canonical ordered followers ([{n, ip}]) — used to label the Follower picker.
        fleetFollowers: Array.isArray(msg.fleetFollowers) ? msg.fleetFollowers : (t.state.fleetFollowers || []),
        // 0.10.0: the numbered launchable decks in KueSync Link ([{num,n,title,rel}]) — used by the Launch action/presets.
        shows: Array.isArray(msg.shows) ? msg.shows : (t.state.shows || []),
        // 0.10.29: the agent's MEASURED double-tap min gap (ms) — Companion uses the slowest machine's value to
        // de-dup its own rapid taps at the same rate, so mashing never stacks/keeps advancing after release.
        advanceMinGapMs: msg.advanceMinGapMs != null ? Number(msg.advanceMinGapMs) : (t.state.advanceMinGapMs || 0),
      }
      // Per-agent license report — the leader agent that holds the key authorizes the whole fleet
      // (and Companion), so the operator only enters the key once, on that machine.
      if (msg.licenseState != null) {
        t.license = { licensed: !!msg.licensed, state: msg.licenseState, licensedTo: msg.licensedTo || '', trialDaysLeft: msg.trialDaysLeft || 0 }
      }
      if (msg.presentationName != null || msg.slideName != null || msg.notes != null) {
        t.meta = { presentationName: msg.presentationName || '', slideName: msg.slideName || '', notes: msg.notes || '' }
      }
      t.connected = true
      // Reconcile the optimistic intended slide with reality ONLY when the operator is idle — so a
      // podium change or drift gets picked up, but a rapid burst of presses isn't clobbered mid-burst.
      if (t === this.primary() && t.state.slide > 0 && Date.now() - this.lastCmdAt > this.IDLE_RECONCILE_MS) {
        this.intendedSlide = t.state.slide
      }
      this._observeCue()
      // build_step resets on every primary slide change (manual tracking; the cue engine sets it itself).
      const ps = this.primarySlide()
      if (ps !== this._lastStepSlide) { this._lastStepSlide = ps; if (!this.cue.running) { this._manualStep = 0; this.setVariableValues({ build_step: 0 }) } }
      this.updateUi()
    } else if (msg.type === 'ack' && msg.ok === false) {
      this.log('warn', `agent ${t.host} rejected: ${msg.error || 'unknown'}`)
      // 0.12.0: surface a control-password mismatch as a VISIBLE connection warning (operators shouldn't have
      // to read the log to learn why nothing drives). Flag the target; updateStatusAggregate() turns it amber
      // with a one-line fix. Cleared on the next accepted command or on (re)connect.
      if (/unauthor|password|token/i.test(String(msg.error || ''))) { t.authRejected = true; try { this.updateStatusAggregate() } catch {} }
    } else if (msg.type === 'ack' && msg.ok === true && t.authRejected) {
      t.authRejected = false; try { this.updateStatusAggregate() } catch {}   // a command got through → password now matches
    }
  }

  // HEALTH: a connected socket is not proof the machine is actually carrying the show — its agent
  // could be wedged or the link half-open. A target is healthy only if it reported (state OR
  // heartbeat) within the staleness window. This is what the green dots should really mean.
  staleMs() { const s = Number(this.config.staleSeconds); return (Number.isFinite(s) && s > 0 ? s : 6) * 1000 }
  healthy(t) { return !!(t && t.connected && t.lastStateAt && (Date.now() - t.lastStateAt) < this.staleMs()) }
  healthyTargets() { return this.targets.filter((t) => this.healthy(t)) }
  healthyCount() { return this.healthyTargets().length }
  connectedCount() { return this.targets.filter((t) => t.connected).length }

  // ----------------------------- current Leader (v6 Leader/Follower) -----------------------------
  // Strip any :port and lowercase, so leader-IP comparisons are robust ('192.168.1.51:37100' == '192.168.1.51').
  // W-S3: keep IPv6 intact — bracketed [fe80::1]:port → fe80::1, bare IPv6 (multiple colons) → unchanged,
  // host:port (exactly one colon) → host. (The old split(':')[0] mangled every IPv6 address.)
  normHost(h) {
    const s = String(h || '').trim().toLowerCase()
    if (s[0] === '[') { const e = s.indexOf(']'); return e > 0 ? s.slice(1, e) : s.slice(1) }
    const c = s.split(':')
    return c.length === 2 ? c[0] : s
  }

  // Resolve who is driving the fleet right now, identified by BOTH machine number and IP.
  //   machine = the agent's slot in the configured IP list (Machine 1..8), matching slide_mN.
  //   host    = its IP/host as configured.
  // Source of truth, in order:
  //   1) a HEALTHY machine that self-reports state.role === 'leader' (the Leader drives itself);
  //   2) otherwise, the Leader IP that the most healthy Followers say they're tracking (state.leaderHost).
  // An inferred Leader IP that isn't one of our configured machines is reported with machine = null.
  // No leader info (pre-v6 agents, or Companion-only operation) -> { present:false }.
  currentLeader() {
    const targets = this.targets || []

    // 1) Self-reported active Leader (prefer healthy; tolerate a transient unhealthy one during failover).
    let leaderT = targets.find((t) => this.healthy(t) && t.state && t.state.role === 'leader')
                || targets.find((t) => t.state && t.state.role === 'leader')

    // 2) Infer from Followers' reported leaderHost (majority vote among healthy followers).
    let inferredHost = null
    if (!leaderT) {
      const votes = new Map()
      for (const t of targets) {
        if (!this.healthy(t)) continue
        const h = t.state && t.state.leaderHost
        if (!h) continue
        const key = this.normHost(h)
        votes.set(key, (votes.get(key) || 0) + 1)
      }
      let best = 0
      for (const [h, n] of votes) if (n > best) { best = n; inferredHost = h }
      if (inferredHost) leaderT = targets.find((t) => this.normHost(t.host) === inferredHost)
    }

    if (leaderT) {
      const i = targets.indexOf(leaderT)
      return { present: true, machine: i >= 0 ? i + 1 : null, host: leaderT.host, name: (leaderT.state && leaderT.state.label) || '', healthy: this.healthy(leaderT) }
    }
    if (inferredHost) { const it = targets.find((t)=>this.normHost(t.host)===inferredHost); return { present: true, machine: null, host: inferredHost, name: (it && it.state && it.state.leaderName) || (it && it.state && it.state.label) || '', healthy: true } } // off-list / manual Leader
    return { present: false, machine: null, host: null, name: '', healthy: false }
  }

  // Feedback follows the first HEALTHY machine, not always slot 0 — so if the main machine dies the
  // backups keep the readouts alive instead of freezing/zeroing. Falls back to slot 0 if none are
  // healthy (so callers always get a target).
  primary() { return this.healthyTargets()[0] || this.targets[0] }

  // Cross-machine drift detector over HEALTHY machines (a stale machine isn't "in sync", it's gone).
  // If any healthy machine is on a different slide than the rest, the fleet has slipped. We stay
  // quiet for a settling window right after a command, when slides legitimately differ as agents
  // catch up. NOTE: this is slide-number parity only — see the build-step caveat in the README.
  fleetSync() {
    const conn = this.healthyTargets().filter((t) => t.state && t.state.slide > 0)
    if (conn.length <= 1) return { inSync: true, spread: 0, n: conn.length, settling: false }
    if (Date.now() - this.lastCmdAt < this.SYNC_SETTLE_MS) {
      return { inSync: true, spread: 0, n: conn.length, settling: true }
    }
    const slides = conn.map((t) => t.state.slide)
    const max = Math.max(...slides), min = Math.min(...slides)
    let inSync = max === min
    const spread = max - min
    // BUILD-LEVEL drift: when every healthy machine reports a real build index (Windows PowerPoint)
    // and they're all on the same slide, compare build positions too — catches "same slide, one build
    // behind", the blind spot Keynote can't see. (Gracefully off for Keynote/Mac, which report -1.)
    let buildAware = false
    if (inSync) {
      const builds = conn.map((t) => t.state.buildIndex)
      if (builds.every((b) => b != null && b >= 0)) {
        buildAware = true
        if (Math.max(...builds) !== Math.min(...builds)) inSync = false
      }
    }
    return { inSync, spread, n: conn.length, settling: false, buildAware }
  }

  // Optional: if the fleet stays out of sync past the settle window for the configured hold, snap
  // everyone to the primary's slide. OFF by default (the snap skips builds) — for video walls/static.
  maybeAutoResync() {
    if (!this.config.autoResync || this.cue.running) { this._driftSince = 0; return }
    const fs = this.fleetSync()
    const now = Date.now()
    if (fs.inSync || fs.n <= 1 || fs.settling) { this._driftSince = 0; return }
    if (!this._driftSince) { this._driftSince = now; return }
    const holdMs = (Number(this.config.autoResyncSeconds) || 4) * 1000
    if (now - this._driftSince >= holdMs) {
      this._driftSince = 0
      const n = this.primarySlide()
      if (n > 0) { this.log('info', `auto-resync: fleet drifted, snapping all to slide ${n}`); this.gotoSlide(n) }
    }
  }

  updateStatusAggregate() {
    const h = this.healthyCount()
    const total = this.targets.length
    // 0.12.0: a control-password mismatch is a config error the operator must SEE, not dig out of the log.
    const cpassBad = (this.targets || []).filter((t) => t.authRejected).map((t) => t.baseHost || t.host)
    if (cpassBad.length) this.updateStatus(InstanceStatus.BadConfig, `Fleet Password mismatch (${cpassBad.join(', ')}) — set the SAME password on the agents and in this connection`)
    else if (this.fleetEntitlement().state === 'expired') this.updateStatus(InstanceStatus.BadConfig, this._enfBlocked ? (this._enfReason === 'revoked' ? 'License revoked — contact kuesync.com' : (String(this._enfReason || '').startsWith('seat') || String(this._enfReason || '') === 'not-activated' ? 'Seat not activated — add a seat at kuesync.com' : 'Trial expired — license required')) : 'Trial expired — license required')
    else if (total === 0) this.updateStatus(InstanceStatus.BadConfig, 'No agent IPs configured')
    else if (h === 0) this.updateStatus(InstanceStatus.Disconnected, `0/${total} healthy`)
    else if (h < total) {
      const down = this.targets.filter((t) => !this.healthy(t)).map((t) => t.baseHost || t.host).join(', ')
      this.updateStatus(InstanceStatus.UnknownWarning, `${h}/${total} healthy — down: ${down}`)
    }
    else this.updateStatus(InstanceStatus.Ok, `${h}/${total} healthy`)
  }

  // Effective entitlement for the whole control setup: Companion's own license/trial OR any connected
  // agent that holds a valid key (a primary). Reports which machine is providing the authorization, so
  // the license button can show e.g. "Licensed · 192.168.1.51". Existing Companion keys still work.
  fleetEntitlement() {
    const own = this.license.evaluate()
    // 0.12.52: a signature-valid own key that the server has REVOKED, or that is over its seat count / never
    // activated past the ≤24h ladder (_enfBlocked, computed off the command path), no longer licenses the fleet —
    // fall through to any connected licensed agent, else expired. A healthy key is completely unaffected.
    if (own.state === 'licensed' && !this._enfBlocked) return { state: 'licensed', trialDaysLeft: 0, licensedTo: own.licensedTo, provider: 'Companion' }
    for (const t of this.targets) {
      if (this.healthy(t) && t.license && t.license.licensed) {
        return { state: 'licensed', trialDaysLeft: 0, licensedTo: t.license.licensedTo, provider: t.host }
      }
    }
    if (own.state === 'trial') return { state: 'trial', trialDaysLeft: own.trialDaysLeft, licensedTo: '', provider: 'Companion' }
    let best = null
    for (const t of this.targets) {
      if (this.healthy(t) && t.license && t.license.state === 'trial') {
        if (!best || (t.license.trialDaysLeft || 0) > best.trialDaysLeft) best = { trialDaysLeft: t.license.trialDaysLeft || 0, provider: t.host }
      }
    }
    if (best) return { state: 'trial', trialDaysLeft: best.trialDaysLeft, licensedTo: '', provider: best.provider }
    return { state: 'expired', trialDaysLeft: 0, licensedTo: '', provider: 'Companion' }
  }

  // ----------------------------- commands (broadcast to all) -----------------------------
  sendCommand(cmd, only = null) {
    if (this.fleetEntitlement().state === 'expired') {
      this.log('warn', 'KueSync trial expired — command blocked.')
      return false
    }
    const base = this.buildPayloadObj(cmd)
    // TARGETED (v7.0.0 fix): a machines filter means THESE machines only. Stamp it so an agent that
    // is the active Leader does NOT fan the command out to the whole fleet, and an agent OWNED by a
    // Leader executes it directly instead of deferring to the funnel (which would never carry it).
    if (only) base.targeted = true
    // 0.10.30: INDEPENDENT OPERATOR — when this is a full-fleet broadcast (no machine filter) to MORE than
    // one machine, Companion has already reached every Follower itself. Stamp `coversFleet` so a control-only
    // active Leader does NOT re-fan it (which would advance every Follower a SECOND time). With only the Leader
    // configured (length 1, e.g. a control-only Leader you point Companion at) the flag stays off, so the
    // Leader still fans to its Followers — exactly the case the 0.10.9 control-only fan exists for.
    else if ((this.targets || []).length > 1) base.coversFleet = true
    let sent = 0
    for (const t of (only || this.targets)) {
      if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) continue
      // try/catch EACH send: a socket can die in the race after the readyState check, and one dead
      // machine must never stop the rest of the fleet from receiving the command.
      try { t.ws.send(JSON.stringify(this._forTarget(base, t))); sent++ }
      catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`) }
    }
    if (!sent) this.log('warn', 'No connected agents; command dropped.')
    return sent > 0
  }

  // 0.10.19: send a command WITHOUT the slide-flip sync gate (no fireAtMs / syncDelayMs). A deck LAUNCH
  // (open + start) must run immediately on each agent — exactly like the Leader's own launch fan and KueSync
  // Control's /leader/launch. Gating these was the bug: the agent scheduled 'open'/'start' on the flip gate,
  // so the deck switched apps but never started (Mac) or did nothing (Windows). Still `direct: true`, so it
  // drives each agent locally and works WITH or WITHOUT a Leader (co-equal). Honors the optional machine filter.
  sendCommandImmediate(cmd, only = null) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — command blocked.'); return false }
    const base = { type: 'command', id: String(++this.cmdId), direct: true, ...cmd }
    if (only) base.targeted = true
    else if ((this.targets || []).length > 1) base.coversFleet = true   // 0.10.30: independent-operator — no control-only re-fan
    let sent = 0
    for (const t of (only || this.targets)) {
      if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) continue
      try { t.ws.send(JSON.stringify(base)); sent++ }
      catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`) }
    }
    if (!sent) this.log('warn', 'No connected agents; launch command dropped.')
    return sent > 0
  }

  // Translate an absolute fire time into THIS machine's clock using its measured offset, so every
  // machine fires at the same physical instant (offsets are 0 until the agent answers a sounding).
  _forTarget(base, t) {
    if (base.fireAtMs > 0 && t.clockOffset) return { ...base, fireAtMs: base.fireAtMs + t.clockOffset }
    return base
  }

  // Resolve a "machines" filter to a subset of targets. Blank / "all" → null (= whole fleet).
  //
  // TWO addressing schemes are accepted, by design, and both keep working:
  //   • STABLE (recommended, what the dropdowns emit): a machine's IP/host. A button saved with an IP always
  //     hits that physical machine, even if the config slot boxes are later reordered. The follower-picker
  //     dropdowns (actions + the follower_active feedback) and the Launch action all emit IPs.
  //   • LEGACY box-order number (1..8): the position in the IP slot boxes, also what the slide_mN / active_mN
  //     variables are keyed to. Older buttons (pre-0.9.8) used these. They still resolve, BUT if you reorder
  //     the slot boxes, a number now points at whatever machine sits in that box — so the physical target can
  //     shift. To pin an old button to a machine for good, re-save it with the dropdown (it locks in the IP).
  // Note the matching tension: actions/feedbacks resolve by EITHER scheme here, while the slide_mN/active_mN
  // VARIABLES are always box-order. They agree as long as you don't reorder the boxes mid-session; if you do,
  // restart the connection (presets regenerate to the new order) — or just use the name·IP dropdowns.
  resolveTargets(spec) {
    const s = String(spec || '').trim().toLowerCase()
    if (!s || s === 'all' || s === '*') return null
    const wanted = new Set(s.split(/[\s,;]+/).filter(Boolean))
    const out = []
    this.targets.forEach((t, i) => {
      const base = String(t.baseHost || t.host).toLowerCase()
      if (wanted.has(String(i + 1)) || wanted.has(t.host.toLowerCase()) || wanted.has(base) ||
          wanted.has(`${t.host}:${t.port}`.toLowerCase()) || wanted.has(`${base}:${t.port}`)) out.push(t)
    })
    return out
  }

  // Build the wire payload, attaching the chosen sync timing:
  //  • Time-of-Day (default): one ABSOLUTE fire time (this Mac's wall clock); all agents fire at it,
  //    absorbing delivery jitter — tightest, assumes NTP-synced clocks (macOS default on a LAN).
  //  • Relative: a delay each agent applies from ITS OWN receipt — clock-independent fallback.
  buildPayloadObj(cmd) {
    const win = Number(this.config.syncWindow)
    const w = Number.isFinite(win) && win > 0 ? win : 0
    const id = String(++this.cmdId)
    const fields = (this.config.syncMode === 'relative')
      ? { syncDelayMs: w }
      : { fireAtMs: Date.now() + w }
    // `direct: true` — Companion fans every command to ALL its configured machines itself, so it is a
    // CO-EQUAL leader: each agent executes the command locally, an active Leader does NOT re-fan it
    // (that would double-advance the fleet), and a Follower does NOT defer it to its Leader. This lets
    // Companion drive a machine that already has a Leader and lead the Leader itself.
    return { type: 'command', id, direct: true, ...cmd, ...fields }
  }

  // 0.12.16 VIDEO COUNT-OUT (agent-fanned). The leader computes a per-source, honesty-gated count-out and fans
  // it on the timer wire; here we read the source this Companion is pinned to (config.videoSource, default
  // 'auto'). Gated on liveness the same as everything else — never surface a count off a dead leader. Returns a
  // videoclock-style reading, or { show:false } when there's nothing honest to show.
  ksVideo() {
    if (!this._timerLive || !this._timerLive()) return { show: false }
    const p = this.primary(); const w = p && p.timer && p.timer.video
    if (!w || !w.sources) return { show: false }
    const pin = String(this.config.videoSource || 'auto')
    if (pin === 'off') return { show: false }
    const src = (pin === 'auto') ? w.sources.auto : w.sources[pin]
    if (!src || !src.show) return { show: false }
    // INTERPOLATE between fans, exactly like kuetimer() does for the main clock: the wire arrives ~every 0.6s
    // while a clip plays, but a GPI rigged "10s before the end" must be frame-close, not quantized to the fan.
    // Count the remaining down by the time since we received this timer object, only while it is playing.
    const dt = Math.max(0, Date.now() - (p.timer.at || Date.now()))
    const remainingMs = src.playing ? Math.max(0, (Number(src.remainingMs) || 0) - dt) : (Number(src.remainingMs) || 0)
    const warnMs = Number(w.warnMs) || 10000
    let state = src.state
    if (remainingMs <= 0) state = 'over'
    else if (remainingMs <= warnMs) state = 'warn'
    else if (src.playing) state = 'running'
    const p2 = (n) => (n < 10 ? '0' : '') + n
    const s = Math.max(0, Math.round(remainingMs / 1000))
    return { ...src, remainingMs, state, text: Math.floor(s / 60) + ':' + p2(s % 60) }
  }

  // Send a command to ONE machine (used to realign a machine that just reconnected without
  // disturbing the rest of the fleet).
  sendToTarget(t, cmd) {
    if (this.fleetEntitlement().state === 'expired') return false
    if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) return false
    try { t.ws.send(JSON.stringify(this._forTarget({ ...this.buildPayloadObj(cmd), targeted: true }, t))) }
    catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`); return false }
    return true
  }

  // 0.12.2 LIVE-ONLY FLEET LICENSE: when THIS Companion holds a valid licence, assert it to every connected
  // agent so a fleet whose agents are all unlicensed still functions WHILE this licensed Companion is present.
  // The agent verifies the key's signature and honours it on a short TTL; it lapses when we disconnect. Sent on
  // connect and every ~30s as a keep-alive. No-op unless our own key is licensed (an agent-licensed fleet
  // doesn't need it). Sent raw (not via sendToTarget) so it isn't gated by fleet-expired.
  pushLiveLicense() {
    try {
      const own = this.license && this.license.evaluate()
      if (!own || own.state !== 'licensed') return
      if (this._enfBlocked) return   // 0.12.52: never assert a revoked / over-seat key to the fleet
      const key = this.config.licenseKey || ''
      if (!key) return
      for (const t of (this.targets || [])) {
        if (t.ws && t.ws.readyState === WebSocket.OPEN) {
          try { t.ws.send(JSON.stringify({ type: 'license-live', key, id: 'lic' + Date.now() })) } catch {}
        }
      }
    } catch {}
  }

  // ----------------------------- KueSync Timer (0.10.2) -----------------------------
  // Send a timer command to the LEADER (Companion's primary). The leader applies it and FANS it fleet-wide,
  // so sending to ONE machine (not broadcasting) avoids each follower becoming a separate timer authority.
  timerCmd(obj) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — timer blocked.'); return false }
    const p = this.primary()
    if (!(p && p.ws && p.ws.readyState === WebSocket.OPEN)) { this.log('warn', 'No connected leader; timer command dropped.'); return false }
    try { p.ws.send(JSON.stringify({ type: 'timerCmd', id: String(++this.cmdId), ...obj })); return true }
    catch (e) { this.log('warn', `timer send failed: ${e.message}`); return false }
  }
  // 0.12.15: cue transport. Same shape and same guard as timerCmd — one connected leader, one authenticated
  // socket. TAKE from a Streamdeck and TAKE in KueSync Control land on the identical server function, so the
  // two can never behave differently.
  cueCmd(obj) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — cue blocked.'); return false }
    const p = this.primary()
    if (!(p && p.ws && p.ws.readyState === WebSocket.OPEN)) { this.log('warn', 'No connected leader; cue command dropped.'); return false }
    try { p.ws.send(JSON.stringify({ type: 'cueCmd', id: String(++this.cmdId), ...obj })); return true }
    catch (e) { this.log('warn', `cue send failed: ${e.message}`); return false }
  }
  // 0.12.15: one sender for the remaining show-surface commands. Same guard as timerCmd/cueCmd.
  showCmd(obj) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — command blocked.'); return false }
    const p = this.primary()
    if (!(p && p.ws && p.ws.readyState === WebSocket.OPEN)) { this.log('warn', 'No connected leader; command dropped.'); return false }
    try { p.ws.send(JSON.stringify({ id: String(++this.cmdId), ...obj })); return true }
    catch (e) { this.log('warn', `send failed: ${e.message}`); return false }
  }
  // The authoritative KueSync Timer (from the primary/leader), remaining/elapsed rebased onto NOW for display.
  kuetimer() {
    const p = this.primary(); const w = p && p.timer
    if (!w) return { present: false, mode: 'down', running: false, remainingMs: 0, elapsedMs: 0, warnMs: 0, durationMs: 0, endAction: 'blink', zeroColor: 'red', message: '', state: 'idle' }
    const dt = Date.now() - (w.at || Date.now())
    let remainingMs = w.remainingMs, elapsedMs = w.elapsedMs
    // The REAL remaining must be rebased by the same elapsed delta, but scaled by the warp factor: the
    // displayed clock runs `factor` times faster than real time, so dt of wall time consumes dt/factor of the
    // displayed number... no — the wire's displayed value already equals real*factor, so rebasing real by dt
    // and re-deriving displayed keeps the two exactly consistent between wire updates.
    const fac = (w.warp && w.factor > 0) ? w.factor : 1
    let realRemainingMs = (w.realRemainingMs != null) ? w.realRemainingMs : w.remainingMs
    if (w.running && w.mode !== 'up') { realRemainingMs = realRemainingMs - dt; remainingMs = Math.round(realRemainingMs * fac) }
    else if (w.running) { elapsedMs = w.elapsedMs + dt }
    let state
    if (w.mode === 'tod') state = 'tod'
    else if (w.mode === 'up') state = w.running ? 'countup' : 'idle'
    else if (!w.running && (w.rev || 0) === 0) state = 'idle'
    else if (remainingMs <= 0) state = 'overtime'
    else if (w.warnMs > 0 && remainingMs <= w.warnMs) state = 'warning'
    else state = w.running ? 'running' : 'set'
    return { present: true, mode: w.mode, running: !!w.running, remainingMs, elapsedMs, warnMs: w.warnMs, durationMs: w.durationMs || 0, endAction: w.endAction, zeroColor: w.zeroColor || 'red', message: w.message || '', state,
      // 0.12.15 operator truth + cue pointers
      endMode: w.endMode || 'clock', warp: !!w.warp, factor: fac, realRemainingMs,
      realDurationMs: w.realDurationMs || 0, outAt: w.outAt || 0, cueId: w.cueId || '',
      pgmIndex: (w.pgmIndex != null) ? w.pgmIndex : -1, pvwIndex: (w.pvwIndex != null) ? w.pvwIndex : -1,
      pgmCue: w.pgmCue || '', pvwCue: w.pvwCue || '', cueCount: w.cueCount || 0,
      cueDeltaMs: w.cueDeltaMs || 0 }
  }
  _fmtTimer(k) {
    const p = (n) => (n < 10 ? '0' : '') + n
    if (k.mode === 'tod') { const d = new Date(); return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) }
    const ms = (k.mode === 'up') ? Math.max(0, k.elapsedMs) : k.remainingMs
    const neg = ms < 0; const t = Math.floor(Math.abs(ms) / 1000); const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60
    return (neg ? '+' : '') + (h > 0 ? (h + ':' + p(m) + ':' + p(s)) : (p(m) + ':' + p(s)))
  }
  // Plain M:SS (or H:MM:SS) for a fixed duration like the limit / warn threshold — no sign, no mode logic.
  _fmtMS(ms) {
    const p = (n) => (n < 10 ? '0' : '') + n
    const t = Math.max(0, Math.round(Number(ms) || 0) / 1000 | 0), h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60
    return h > 0 ? (h + ':' + p(m) + ':' + p(s)) : (p(m) + ':' + p(s))
  }
  _updateTimerVars() {
    const k = this.kuetimer()
    const remS = (k.mode === 'up') ? Math.round(k.elapsedMs / 1000) : Math.round(k.remainingMs / 1000)
    // Human-readable at-zero behaviour + a one-line summary so an operator can SEE the whole timer setup at a glance.
    const endLabel = k.mode !== 'down' ? '—' : (k.endAction === 'up' ? 'Count overtime' : k.endAction === 'zero' ? 'Hold 0:00' : k.endAction === 'blank' ? 'Blank' : 'Blink 0:00')
    const modeLabel = k.mode === 'up' ? 'Count up' : (k.mode === 'tod' ? 'Time of day' : 'Countdown')
    const warnLabel = (k.warnMs > 0) ? this._fmtMS(k.warnMs) : 'off'
    const colorBit = (k.mode === 'down' && k.endAction !== 'up') ? ' · ' + String(k.zeroColor || 'red') : ''
    // 0.12.49.1: read 'set' as "Loaded" for operators (armed with a limit, not yet started).
    const stateLabel = k.state === 'set' ? 'Loaded' : (k.state.charAt(0).toUpperCase() + k.state.slice(1))
    let summary
    if (!k.present) summary = 'No leader'
    else if (k.mode === 'tod') summary = 'Time of day'
    else if (k.mode === 'up') summary = 'Count up · ' + stateLabel
    else summary = `${this._fmtMS(k.durationMs)} · warn ${warnLabel} · ${endLabel}${colorBit} · ${stateLabel}`
    // 0.12.15 TIMER CUES. cue_advance_seq is a MONOTONIC counter, not a boolean or a name: a trigger that
    // watches a name would miss two consecutive takes of the same cue, and one that watches an index would
    // miss a re-take of the cue already on air. A counter ticks on every TAKE, always.
    const pgmIdx = (k.pgmIndex != null) ? k.pgmIndex : -1
    const pvwIdx = (k.pvwIndex != null) ? k.pvwIndex : -1
    if (k.present && k.cueId && k.cueId !== this._lastCueId) {
      this._lastCueId = k.cueId
      this._cueSeq = (this._cueSeq || 0) + 1
    }
    // A hard/warp cue reports the actual out; a plain clock cue has none, and must show nothing rather than
    // a misleading "now + remaining".
    let outTod = ''
    if (k.outAt && k.endMode !== 'clock') {
      const d = new Date(k.outAt); let hh = d.getHours(); const mm = d.getMinutes()
      const ap = hh >= 12 ? 'pm' : 'am'; hh = hh % 12; if (!hh) hh = 12
      outTod = `${hh}:${mm < 10 ? '0' : ''}${mm} ${ap}`
    }
    this.setVariableValues({
      kuetimer_time: this._fmtTimer(k), kuetimer_state: k.state, kuetimer_mode: k.mode,
      kuetimer_loaded: String(k.state === 'set'),   // 0.12.49.1: armed with a limit but not started
      kuetimer_running: String(!!k.running), kuetimer_remaining_s: remS,
      kuetimer_zerocolor: k.zeroColor || 'red', kuetimer_message: k.message || '',
      kuetimer_limit: this._fmtMS(k.durationMs), kuetimer_warn: warnLabel,
      kuetimer_mode_label: modeLabel, kuetimer_atzero_label: endLabel, kuetimer_summary: summary,
      // ---- cue transport ----
      pgm_cue: k.pgmCue || '', pvw_cue: k.pvwCue || '',
      cue_index: pgmIdx >= 0 ? (pgmIdx + 1) : 0,
      cue_pvw_index: pvwIdx >= 0 ? (pvwIdx + 1) : 0,
      cue_count: k.cueCount || 0,
      cue_position: (pgmIdx >= 0 && k.cueCount) ? `${pgmIdx + 1} / ${k.cueCount}` : '—',
      cue_advance_seq: this._cueSeq || 0,
      // ---- operator truth (never render these on a presenter-facing surface) ----
      kuetimer_endmode: k.endMode || 'clock',
      kuetimer_warp: String(!!k.warp),
      kuetimer_warp_factor: Number(k.factor || 1).toFixed(3),
      kuetimer_realremaining_s: Math.round((k.realRemainingMs != null ? k.realRemainingMs : k.remainingMs) / 1000),
      kuetimer_real_time: this._fmtMS(Math.max(0, k.realRemainingMs != null ? k.realRemainingMs : k.remainingMs)),
      kuetimer_out_tod: outTod,
      cue_delta_s: Math.round((k.cueDeltaMs || 0) / 1000),
      cue_delta: (k.cueDeltaMs ? ((k.cueDeltaMs > 0 ? '+' : '\u2212') + this._fmtMS(Math.abs(k.cueDeltaMs))) : 'on plan'),
    })
    // 0.12.15: the cue/trigger feedbacks are re-checked on every timer update. A Companion TRIGGER watching
    // one of these only fires when the feedback actually re-evaluates, so leaving them out here would mean a
    // trigger that silently never runs — the worst possible failure for automation.
    this.checkFeedbacks('kuetimer', 'kuetimer_message_active',
      'cue_on_air', 'cue_armed', 'cue_warp_active', 'cue_running_behind', 'cue_timer_zero', 'cue_timer_warn')
  }

  // ----------------------------- leader config mirror (0.12.49.1) -----------------------------
  // The look surfaces (backgrounds, chroma text, Kue-Light pips) and the notes SOURCE / backup are LEADER config,
  // reached over HTTP the same way KueSync Control reaches them: an OPEN GET to read, a control-password POST to
  // write. Everything below is Companion-only — the agents are unchanged.

  // Resolve the HTTP endpoint of the LEADER (config is the leader's and fans to the fleet). Prefer the
  // self-reported / inferred leader; fall back to the primary control target (which is what the WS timerCmd
  // already targets, so behaviour is consistent). We must not POST to a non-leader — it would write that
  // machine's own config instead of the fleet's.
  _leaderEndpoint() {
    const ld = this.currentLeader ? this.currentLeader() : { present: false, host: null }
    let t = null
    if (ld && ld.present && ld.host) {
      const want = this.normHost(ld.host)
      t = (this.targets || []).find((x) => this.normHost(x.host) === want || this.normHost(x.baseHost || '') === want)
    }
    if (!t) t = this.primary()
    if (!t || !t.host) return null
    return { host: t.host, port: t.port || Number(this.config.port) || DEFAULT_PORT }
  }

  // GET /leader/config (open, read-only) → snapshot into this.leaderCfg, then refresh look/notes vars + feedbacks.
  _refreshLeaderCfg() {
    if (this._cfgInflight) return
    const ep = this._leaderEndpoint()
    if (!ep) return
    this._cfgInflight = true
    let done = false
    const finish = () => { if (done) return; done = true; this._cfgInflight = false }
    try {
      const req = http.get({ host: ep.host, port: ep.port, path: '/leader/config', timeout: 2000 }, (res) => {
        if (res.statusCode !== 200) { res.resume(); return finish() }
        let buf = ''
        res.setEncoding('utf8')
        res.on('data', (c) => { buf += c; if (buf.length > 262144) { try { req.destroy() } catch {} } })
        res.on('end', () => {
          finish()
          try { const cfg = JSON.parse(buf); if (cfg && typeof cfg === 'object') { this.leaderCfg = cfg; this._applyLeaderCfg() } } catch {}
        })
      })
      req.on('timeout', () => { try { req.destroy() } catch {}; finish() })
      req.on('error', () => finish())
    } catch { finish() }
  }

  // POST /leader/config (merges the patch onto the leader's live config, then it re-fans + auto-refreshes every
  // full-screen surface). Control-password gated with the same token the WS uses. Optimistically mirror locally
  // and re-read shortly after to confirm the leader's canonical value.
  postLeaderConfig(patch) {
    const ep = this._leaderEndpoint()
    if (!ep) { this.log('warn', 'KueSync: no leader reachable to apply config change'); return false }
    const body = JSON.stringify(patch || {})
    const headers = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }
    const tok = String(this.config.token || '').trim(); if (tok) headers['x-kuesync-token'] = tok
    try {
      const req = http.request({ host: ep.host, port: ep.port, path: '/leader/config', method: 'POST', headers, timeout: 2500 }, (res) => {
        const ok = res.statusCode === 200
        res.resume()
        res.on('end', () => {
          if (!ok) this.log('warn', `KueSync config change rejected (HTTP ${res.statusCode})${res.statusCode === 401 ? ' — check the Fleet Password matches the leader' : ''}`)
          try { Object.assign(this.leaderCfg, patch); this._applyLeaderCfg() } catch {}
          setTimeout(() => { try { this._refreshLeaderCfg() } catch {} }, 400)
        })
      })
      req.on('timeout', () => { try { req.destroy() } catch {} })
      req.on('error', (e) => this.log('warn', `KueSync config change failed: ${e.message}`))
      req.write(body); req.end()
      return true
    } catch (e) { this.log('warn', `KueSync config change failed: ${e.message}`); return false }
  }

  _applyLeaderCfg() {
    try { this._updateLookVars() } catch {}
    try { this.checkFeedbacks('notes_is_source', 'notes_backup_set', 'clock_bg_swatch', 'notes_bg_swatch', 'clock_pip_on', 'notes_pip_on', 'clock_chroma_black', 'notes_chroma_black') } catch {}
  }

  _cfg() { return this.leaderCfg || {} }
  lookClockBg() { return String(this._cfg().clockBg || 'black') }
  lookNotesBgRaw() { return String(this._cfg().notesBg || '') }                 // '' = follow the clock bg
  lookClockChroma() { return String(this._cfg().clockChromaText || 'white') }
  lookNotesChroma() { return String(this._cfg().notesChromaText || 'white') }
  lookClockPip() { return !!this._cfg().clockCuePip }
  lookNotesPip() { return !!this._cfg().notesCuePip }
  lookNotesPipPos() { return String(this._cfg().notesPipPos || 'tr') }
  notesSourceHost() { return String(this._cfg().notesSourceHost || '') }
  notesFailoverHost() { return String(this._cfg().notesFailoverHost || '') }

  // Friendly label for a source host ('' = AUTO, 'self' = the leader, else name·IP if we know the machine).
  sourceLabel(host) {
    const h = String(host || '').trim()
    if (!h) return 'AUTO'
    if (h.toLowerCase() === 'self') return 'Leader'
    const t = (this.targets || []).find((x) => this.normHost(x.baseHost || x.host) === this.normHost(h))
    const nm = t && t.state && t.state.label ? String(t.state.label) : ''
    return nm ? `${nm} · ${h}` : h
  }
  // Dropdown for the notes source / backup pickers: AUTO + Leader + every machine this connection knows.
  sourceChoices() {
    const base = [{ id: '', label: 'AUTO — any machine on the fleet' }, { id: 'self', label: 'Leader (this control target)' }]
    const machines = (this.targets || []).map((t) => {
      const host = String(t.baseHost || t.host)
      const nm = t.state && t.state.label ? String(t.state.label) : ''
      return { id: host, label: `${nm ? nm + ' · ' : ''}${host}` }
    })
    return base.concat(machines)
  }
  _bgLabel(raw) { return raw ? String(raw) : 'follow clock' }
  _updateLookVars() {
    this.setVariableValues({
      notes_source: this.sourceLabel(this.notesSourceHost()),
      notes_backup: this.notesFailoverHost() ? this.sourceLabel(this.notesFailoverHost()) : 'none',
      clock_bg: this.lookClockBg(),
      notes_bg: this._bgLabel(this.lookNotesBgRaw()),
      clock_chroma_text: this.lookClockChroma(),
      notes_chroma_text: this.lookNotesChroma(),
      clock_pip: this.lookClockPip() ? 'on' : 'off',
      notes_pip: this.lookNotesPip() ? 'on' : 'off',
      notes_pip_pos: this.lookNotesPipPos(),
    })
  }

  // --- write helpers used by the look / notes actions ---
  setNotesSource(host) { this.postLeaderConfig({ notesSourceHost: String(host || '') }) }
  setNotesBackup(host) { this.postLeaderConfig({ notesFailoverHost: String(host || '') }) }  // agent drops it if it equals the primary
  setClockBg(v) { this.postLeaderConfig({ clockBg: String(v || 'black') }) }
  setNotesBg(v) { this.postLeaderConfig({ notesBg: String(v || '') }) }
  cycleClockBg() { const o = ['black', 'white', 'green', 'blue']; const i = o.indexOf(this.lookClockBg()); this.setClockBg(o[(i + 1) % o.length]) }
  cycleNotesBg() { const o = ['', 'black', 'white', 'green', 'blue']; const i = o.indexOf(this.lookNotesBgRaw()); this.setNotesBg(o[(i + 1) % o.length]) }
  toggleClockChroma() { this.postLeaderConfig({ clockChromaText: this.lookClockChroma() === 'black' ? 'white' : 'black' }) }
  toggleNotesChroma() { this.postLeaderConfig({ notesChromaText: this.lookNotesChroma() === 'black' ? 'white' : 'black' }) }
  setClockPip(on) { this.postLeaderConfig({ clockCuePip: !!on }) }
  setNotesPip(on) { this.postLeaderConfig({ notesCuePip: !!on }) }
  setNotesPipPos(pos) { this.postLeaderConfig({ notesPipPos: String(pos || 'tr') }) }
  // Timer at-zero colour / behaviour cycles — these ride the existing WS timerCmd (no config write).
  cycleTimerColor() { const o = ['red', 'amber', 'white', 'green', 'blue']; const i = o.indexOf(String(this.kuetimer().zeroColor || 'red')); this.timerCmd({ action: 'set', zeroColor: o[(i + 1) % o.length] }) }
  cycleTimerEndMode() { const o = ['blink', 'zero', 'blank', 'up']; const i = o.indexOf(String(this.kuetimer().endAction || 'blink')); this.timerCmd({ action: 'set', endAction: o[(i + 1) % o.length] }) }

  // ----------------------------- timer cues + back-timing (0.12.49.2) -----------------------------
  // The native KueSync Control cue list + hard-out/warp back-timing, reached over HTTP with the Fleet Password
  // Companion already holds. Reads: GET /leader/timercues. Authoring: POST /leader/timercues?op=…. Back-timing:
  // POST /leader/timer?endmode=… (the leader resolves the time-of-day ONCE so every monitor lands on the same
  // instant). All target the active leader and self-relay. Transport (take/preview) still rides the WS cueCmd.

  // Low-level JSON HTTP to a host:port with the control-password header. cb(err, parsedJsonOrNull).
  _httpJson(opts, cb) {
    const done = { v: false }
    const finish = (e, j) => { if (done.v) return; done.v = true; cb && cb(e, j) }
    try {
      let p = opts.path || '/'
      if (opts.query && typeof opts.query === 'object') {
        const qs = Object.entries(opts.query)
          .filter(([, v]) => v != null && v !== '')
          .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
        if (qs) p += (p.includes('?') ? '&' : '?') + qs
      }
      const headers = {}
      const tok = String(this.config.token || '').trim(); if (tok) headers['x-kuesync-token'] = tok
      let body = null
      if (opts.bodyObj !== undefined) { body = JSON.stringify(opts.bodyObj); headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(body) }
      const req = http.request({ host: opts.host, port: opts.port, path: p, method: opts.method || 'GET', headers, timeout: opts.timeoutMs || 2500 }, (res) => {
        let buf = ''; res.setEncoding('utf8')
        res.on('data', (c) => { buf += c; if (buf.length > 1048576) { try { req.destroy() } catch {} } })
        res.on('end', () => { let j = null; try { j = buf ? JSON.parse(buf) : null } catch {} finish((res.statusCode >= 200 && res.statusCode < 300) ? null : new Error('HTTP ' + res.statusCode + (res.statusCode === 401 ? ' — check the Fleet Password matches the leader' : '')), j) })
      })
      req.on('timeout', () => { try { req.destroy() } catch {}; finish(new Error('timeout')) })
      req.on('error', (e) => finish(e))
      if (body != null) req.write(body)
      req.end()
    } catch (e) { finish(e) }
  }
  _leaderRequest(opts, cb) {
    const ep = this._leaderEndpoint()
    if (!ep) { cb && cb(new Error('no leader reachable')); return }
    this._httpJson({ ...opts, host: ep.host, port: ep.port }, cb)
  }
  // POST /leader/timer with query params (the leader resolves end-mode intent). See index.js /leader/timer.
  postLeaderTimer(query, cb) {
    this._leaderRequest({ method: 'POST', path: '/leader/timer', query }, (err, j) => {
      if (err) this.log('warn', 'KueSync timer command failed: ' + err.message)
      else if (j && j.ok === false) this.log('warn', 'KueSync timer: ' + (j.error || 'refused'))
      cb && cb(err, j)
    })
  }
  // POST /leader/timercues?op=… — the one cue-authoring endpoint (add/update/del/move/clear/load).
  cueOp(op, { query = {}, body } = {}, cb) {
    this._leaderRequest({ method: 'POST', path: '/leader/timercues', query: { op, ...query }, bodyObj: body }, (err, j) => {
      if (err) this.log('warn', `KueSync cue ${op} failed: ${err.message}`)
      else if (j && j.ok === false) this.log('warn', `KueSync cue ${op}: ${j.error || 'refused'}`)
      else setTimeout(() => { try { this._refreshCues() } catch {} }, 300)
      cb && cb(err, j)
    })
  }
  // GET the cue list → snapshot into this.cuesData; refresh cue vars, pickers/presets, and cue feedbacks.
  _refreshCues() {
    if (this._cuesInflight) return
    const ep = this._leaderEndpoint(); if (!ep) return
    this._cuesInflight = true
    this._leaderRequest({ method: 'GET', path: '/leader/timercues', timeoutMs: 2500 }, (err, j) => {
      this._cuesInflight = false
      if (err || !j || j.ok === false) return
      const cues = Array.isArray(j.cues) ? j.cues : []
      this.cuesData = { cues, count: j.count != null ? j.count : cues.length, pgmIndex: j.pgmIndex != null ? j.pgmIndex : -1,
        pvwIndex: j.pvwIndex != null ? j.pvwIndex : -1, endsAt: j.endsAt || 0, plannedEndsAt: j.plannedEndsAt || 0,
        deltaMs: j.deltaMs || 0, totalRealMs: j.totalRealMs || 0 }
      this._applyCuesData()
    })
  }
  _applyCuesData() {
    try { this._updateCueVars() } catch {}
    try { this.republishFollowerUi() } catch {}   // a cue-list change re-publishes the pickers + per-cue TAKE presets
    try { this.checkFeedbacks('cue_on_air', 'cue_armed') } catch {}
  }
  // Signature of the cue list (identity + order + count) — republish pickers/presets only when it actually changes.
  cueSig() { return (this.cuesData.cues || []).map((c) => `${c.id || ''}:${c.name || ''}`).join('|') + '#' + (this.cuesData.count || 0) }
  // Dropdown of the real cue names for the pickers (keyed by stable id so "fire this cue" survives a reorder).
  cueChoices() {
    const cs = this.cuesData.cues || []
    if (!cs.length) return [{ id: '', label: '(no cues loaded — build or load a cue list)' }]
    return cs.map((c, i) => ({ id: c.id || String(i + 1), label: `${i + 1}. ${c.name || '(unnamed)'}` }))
  }
  // Resolve a picker value (cue id, name, or a literal 1-based position) to the CURRENT 1-based index for cueCmd.
  cueIndexOf(sel) {
    const s = String(sel == null ? '' : sel).trim()
    if (!s) return 0
    if (/^\d+$/.test(s)) return Number(s)
    const cs = this.cuesData.cues || []
    let i = cs.findIndex((c) => c.id === s)
    if (i < 0) i = cs.findIndex((c) => String(c.name || '').trim().toLowerCase() === s.toLowerCase())
    return i >= 0 ? i + 1 : 0
  }
  _cueById(sel) {
    const s = String(sel == null ? '' : sel).trim(); const cs = this.cuesData.cues || []
    if (/^\d+$/.test(s)) { return cs[Number(s) - 1] || null }
    return cs.find((c) => c.id === s) || cs.find((c) => String(c.name || '').trim().toLowerCase() === s.toLowerCase()) || null
  }
  // Transport by name/id (survives reorder) — still the WS cueCmd wire.
  cueTakeSel(sel) { const n = this.cueIndexOf(sel); this.cueCmd(n >= 1 ? { action: 'take', cue: n } : { action: 'take' }) }
  cuePreviewSel(sel) { const n = this.cueIndexOf(sel); if (n >= 1) this.cueCmd({ action: 'preview', cue: n }) }
  // Authoring wrappers.
  cueAdd({ name = '', minutes = 5, seconds = 0, warnSec = 0, end = 'blink', speaker = '', note = '' } = {}) {
    const durationMs = ((Number(minutes) || 0) * 60 + (Number(seconds) || 0)) * 1000
    const body = { name: String(name || ''), durationMs: durationMs > 0 ? durationMs : 300000, endAction: end }
    if (warnSec > 0) body.warnMs = warnSec * 1000
    if (speaker) body.speaker = String(speaker)
    if (note) body.note = String(note)
    this.cueOp('add', { body })
  }
  cueUpdate(sel, fields) { const c = this._cueById(sel); if (!c) { this.log('warn', 'cue update: no such cue'); return } this.cueOp('update', { query: { id: c.id }, body: fields || {} }) }
  cueDel(sel) { const c = this._cueById(sel); if (!c) { this.log('warn', 'cue delete: no such cue'); return } this.cueOp('del', { query: { id: c.id } }) }
  cueMove(sel, toPos) { const c = this._cueById(sel); if (!c) { this.log('warn', 'cue move: no such cue'); return } this.cueOp('move', { query: { id: c.id, to: Math.max(0, (Number(toPos) || 1) - 1) } }) }
  cueClear() { this.cueOp('clear', {}) }
  cueLoad(text, mode = 'replace') { this.cueOp('load', { query: { mode: mode === 'append' ? 'append' : 'replace' }, body: { text: String(text || '') } }) }
  // Back-timing: hard out at a wall-clock time, and warp/pace-to-out.
  setHardOut(outStr, { minutes = 0, seconds = 0, warnSec = 0, start = true } = {}) {
    const dur = ((Number(minutes) || 0) * 60 + (Number(seconds) || 0)) * 1000
    const q = { endmode: 'hard', out: String(outStr || '').trim(), action: 'set' }
    if (dur > 0) q.dur = dur
    if (warnSec > 0) q.warn = warnSec * 1000
    this.postLeaderTimer(q, (err) => { if (!err && start) this.postLeaderTimer({ action: 'start' }); setTimeout(() => { try { this._refreshCues() } catch {} }, 400) })
  }
  setWarp({ to = 'tod', outStr = '', minutes = 0, seconds = 0, realMin = 0, keep = false, warnSec = 0, start = true } = {}) {
    const dur = ((Number(minutes) || 0) * 60 + (Number(seconds) || 0)) * 1000   // what the PRESENTER sees
    const q = { endmode: 'warp', warpto: (to === 'dur' ? 'dur' : 'tod'), action: 'set' }
    if (dur > 0) q.dur = dur
    if (warnSec > 0) q.warn = warnSec * 1000
    if (to === 'dur') q.realdur = (Number(realMin) || 0) * 60000
    else q.out = String(outStr || '').trim()
    if (keep) { q.warpkeep = 1; start = false }   // seamless update of a RUNNING clock — do not restart
    this.postLeaderTimer(q, (err) => { if (!err && start) this.postLeaderTimer({ action: 'start' }) })
  }
  _fmtTod(ms) { const d = new Date(ms); let h = d.getHours(); const m = d.getMinutes(); const ap = h >= 12 ? 'pm' : 'am'; h = h % 12 || 12; return `${h}:${m < 10 ? '0' : ''}${m} ${ap}` }
  _updateCueVars() {
    const d = this.cuesData || { cues: [] }
    const cs = d.cues || []
    const names = cs.length
      ? cs.map((c, i) => `${i === d.pgmIndex ? '▶' : i === d.pvwIndex ? '▷' : '·'} ${i + 1}. ${c.name || '(unnamed)'}`).join('\n')
      : '(no cues)'
    this.setVariableValues({
      cues_total_time: this._fmtMS(d.totalRealMs || 0),
      show_end_tod: d.endsAt ? this._fmtTod(d.endsAt) : '—',
      show_end_planned_tod: d.plannedEndsAt ? this._fmtTod(d.plannedEndsAt) : '—',
      cue_list: names,
    })
  }

  // ----------------------------- follower Active (0.9.6) -----------------------------
  machineNumberOf(t) { const i = this.targets.indexOf(t); return i >= 0 ? i + 1 : '?' }
  // Resolve a single machine spec (number 1..8, host, or host:port) to one target.
  targetFor(machineSpec) { const only = this.resolveTargets(machineSpec); return (only && only.length) ? only[0] : null }
  // Read a machine's current Active state (default true if unknown) — used by the toggle action + feedback.
  followerActiveOf(machineSpec) { const t = this.targetFor(machineSpec); return t && t.state ? t.state.followerActive !== false : true }
  // 0.12.53: read a machine's current SLIDE-DRIVER state (default false) — used by the toggle action + feedback.
  slideDriverOf(machineSpec) { const t = this.targetFor(machineSpec); return t && t.state ? t.state.slideDriver === true : false }

  // Live dropdown of the machines this connection knows about, so the Follower buttons are picked BY NAME/IP
  // instead of a bare position number (the position number is just the order of the IP boxes, which need not
  // match the leader's follower numbering — that mismatch is what caused "2 toggled 3"). The choice VALUE is
  // the machine's IP, so a button always hits the same physical machine regardless of box order. Numbers and
  // IPs still work (allowCustom on the dropdown + resolveTargets), so existing buttons never break.
  // The target we currently consider the active Leader (it publishes the canonical followers list, if any).
  leaderTarget() {
    return (this.targets || []).find((t) => t.state && (t.state.role === 'leader' || t.state.role === 'standby') && Array.isArray(t.state.fleetFollowers) && t.state.fleetFollowers.length) || null
  }
  followerChoices() {
    // Numbering is hybrid:
    //   • If a LEADER is on the fleet, use ITS canonical followers list (the #1, #2… in its Settings) so a
    //     button labelled "Follower 2" means the same machine in Settings and Companion.
    //   • If there's NO leader (Companion + followers, a supported setup), AUTO-determine from THIS
    //     connection's own machine list (the IP boxes = Follower 1..8) — no leader required.
    // Either way each choice shows the follower number, DEVICE NAME (learned live), and IP, and is keyed by
    // IP so a button always hits the right physical machine regardless of box order. (Numbers/IPs still work.)
    const nameForIp = (ip) => {
      const t = (this.targets || []).find((x) => this.normHost(x.baseHost || x.host) === this.normHost(ip))
      return t && t.state && t.state.label ? String(t.state.label) : ''
    }
    const lead = this.leaderTarget()
    if (lead) {
      return lead.state.fleetFollowers.map((f) => {
        const ip = String(f.ip), name = nameForIp(ip)
        return { id: ip, label: `Follower ${f.n}${name ? ' · ' + name : ''} · ${ip}` }
      })
    }
    const ch = (this.targets || []).map((t, i) => {
      const host = String(t.baseHost || t.host)
      const name = t.state && t.state.label ? String(t.state.label) : ''
      return { id: host, label: `Follower ${i + 1}${name ? ' · ' + name : ''} · ${host}` }
    })
    return ch.length ? ch : [{ id: '1', label: 'Follower 1 (add machine IPs in this connection first)' }]
  }
  // 0.10.0: the numbered launchable decks (read from any agent's state — KueSync Link is identical fleet-wide).
  allShows() {
    for (const t of (this.targets || [])) { if (t.state && Array.isArray(t.state.shows) && t.state.shows.length) return t.state.shows }
    return []
  }
  // Dropdown of launchable shows for the Launch action/presets ("Launch 006 · Welcome"), keyed by number.
  showChoices() {
    const ch = this.allShows().map((s) => ({ id: String(s.num), label: `Launch ${s.n}${s.title ? ' · ' + s.title : ''}` }))
    return ch.length ? ch : [{ id: '', label: 'No numbered shows in KueSync Link yet' }]
  }
  // Launch a numbered KueSync Link deck on the chosen machines (blank/"all" = whole fleet): OPEN the deck
  // (resolved against each machine's OWN KueSync Link folder via linkRel), then START the show at slide 1 a
  // beat later. Reuses the module's existing fan-out + machine filter, so it targets exactly like advance.
  launchShow(numSpec, machines) {
    const want = parseInt(String(numSpec).trim(), 10)
    const s = this.allShows().find((x) => x.num === want)
    if (!s) { this.log('warn', `launch: no numbered show "${numSpec}" in KueSync Link`); return false }
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'launch: no machines matched the target filter'); return false }
    // 0.10.19: UN-GATED open → (1.4s) → start, mirroring the Leader's launch fan exactly. Previously these
    // went through the flip-sync gate (sendCommand) and the deck wouldn't start. linkRel resolves against each
    // agent's OWN KueSync Link folder, so it lands cross-platform with or without a Leader.
    this.sendCommandImmediate({ action: 'open', linkRel: s.rel }, only)
    setTimeout(() => this.sendCommandImmediate({ action: 'start' }, only), 1400)
    this.log('info', `launch ${s.n}${s.title ? ' · ' + s.title : ''} -> ${machines || 'all machines'}`)
    return true
  }
  // Signatures of the picker contents so we only re-publish dropdowns when something actually changes —
  // never on every poll. Split deliberately:
  //   • fleetSig  — the Follower picker contents (fleet membership, device names, Leader's canonical list).
  //                 Drives the follower-scoped FEEDBACK (follower_active) and the follower ACTION dropdowns.
  //   • showSig   — the numbered shows (0.10.0). Drives only the Launch ACTIONS/PRESETS.
  // Why the split: re-calling setFeedbackDefinitions() re-registers the whole feedback set, which can disturb
  // live button instances (operators saw cue-light buttons needing a re-add). The cue_light feedback is fully
  // static, so it must NOT be re-published just because a deck was launched and the shows list changed. We
  // therefore republish feedbacks ONLY on a real fleet/name change, and actions+presets on either change.
  fleetSig() {
    const lead = this.leaderTarget()
    const canon = lead ? lead.state.fleetFollowers.map((f) => `${f.n}:${f.ip}`).join(',') : ''
    return canon + '||' + (this.targets || []).map((t) => `${t.baseHost || t.host}|${(t.state && t.state.label) || ''}`).join(',')
  }
  showSig() {
    return this.allShows().map((s) => `${s.num}:${s.rel}`).join(',')   // 0.10.0: re-publish Launch buttons when shows change
  }
  // Back-compat alias (some callers/tests reference the combined signature).
  followerChoiceSig() { return this.fleetSig() + '||' + this.showSig() }
  republishFollowerUi() {
    const fSig = this.fleetSig()
    const sSig = this.showSig()
    const cSig = this.cueSig()                       // 0.12.49.2: the cue list → cue pickers + per-cue TAKE presets
    const fleetChanged = fSig !== this._lastFleetSig
    const showsChanged = sSig !== this._lastShowSig
    const cuesChanged = cSig !== this._lastCueSig
    if (!fleetChanged && !showsChanged && !cuesChanged) return
    this._lastFleetSig = fSig
    this._lastShowSig = sSig
    this._lastCueSig = cSig
    try {
      // Actions + presets carry the follower pickers, the Launch list, AND the cue pickers/per-cue buttons →
      // refresh on any of those changing.
      if (fleetChanged || showsChanged || cuesChanged) { this.setActionDefinitions(getActions(this)); this.setPresetDefinitions(getPresets(this)) }
      // Feedbacks only carry the follower picker (follower_active). Re-publish ONLY when the fleet/names change,
      // so launching decks / editing cues never re-registers — and never disturbs — the static cue_light lamp.
      if (fleetChanged) this.setFeedbackDefinitions(getFeedbacks(this))
    } catch {}
  }

  // Park / un-park a follower BY MACHINE NUMBER (Companion Activate / Deactivate / Toggle buttons). Sends
  // the authenticated `setLocalFlag` over that machine's own control socket — NOT the Settings-PIN-gated
  // HTTP path — so it works with or without a Fleet Password. On ACTIVATE we snap that machine back to
  // the fleet's current slide (it ignored cues while parked), unless the caller turns that off.
  setFollowerActive(machineSpec, on, { resync = true } = {}) {
    const t = this.targetFor(machineSpec)
    if (!t) { this.log('warn', `Follower ${on ? 'activate' : 'deactivate'}: no machine "${machineSpec}" configured`); return false }
    if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) { this.log('warn', `Follower ${machineSpec}: not connected — command dropped`); return false }
    try {
      t.ws.send(JSON.stringify({ type: 'setLocalFlag', key: 'followerActive', on: !!on, id: String(++this.cmdId) }))
    } catch (e) { this.log('warn', `Follower set failed for ${t.host}: ${e.message}`); return false }
    t.state.followerActive = !!on   // optimistic so the button flips at once; the agent's state push confirms
    this.log('info', `Follower ${this.machineNumberOf(t)} → ${on ? 'Active' : 'Inactive'}`)
    // A reactivated machine sat out cues, so it's on a stale slide — snap it to the fleet's current slide.
    if (on && resync) { const n = this.primarySlide(); if (n > 0) setTimeout(() => this.sendToTarget(t, { action: 'goto', slide: n }), 250) }
    this.updateUi()
    return true
  }

  // 0.12.53: designate (or clear) a follower as a SLIDE DRIVER — its USB advancer / Perfect Cue / keystrokes drive
  // the whole fleet by relaying to the Leader (which fans with its normal gated sync). Same authenticated
  // setLocalFlag transport as setFollowerActive; the follower persists it, keeps its capture always-armed, and
  // re-broadcasts so this button + feedback confirm. This mirrors the "Slide driver" checkbox in the Leader's
  // Settings — a follower can never set it on itself, but an authorized control surface (this module) can.
  setFollowerSlideDriver(machineSpec, on) {
    const t = this.targetFor(machineSpec)
    if (!t) { this.log('warn', `Slide driver: no machine "${machineSpec}" configured`); return false }
    if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) { this.log('warn', `Slide driver ${machineSpec}: not connected — command dropped`); return false }
    try {
      t.ws.send(JSON.stringify({ type: 'setLocalFlag', key: 'slideDriver', on: !!on, id: String(++this.cmdId) }))
    } catch (e) { this.log('warn', `Slide driver set failed for ${t.host}: ${e.message}`); return false }
    t.state.slideDriver = !!on   // optimistic so the button flips at once; the agent's state push confirms
    this.log('info', `Follower ${this.machineNumberOf(t)} → Slide driver ${on ? 'ON' : 'OFF'}`)
    this.updateUi()
    return true
  }

  // The fleet's current slide. The configured "primary" (first machine) can legitimately report 0 — a
  // control-only Leader that doesn't present, a parked (Inactive) follower, or a machine whose show is
  // stopped. Using that 0 made SYNC NOW snap everyone to slide 1 (0.9.6 fix). Fall back to the first
  // HEALTHY machine actually on a slide so we align to where the show really is.
  primarySlide() {
    const p = this.primary()
    if (p && p.state && p.state.slide > 0) return p.state.slide
    const onShow = this.healthyTargets().find((t) => t.state && t.state.slide > 0)
    return onShow ? onShow.state.slide : 0
  }
  primaryTotal() {
    const p = this.primary()
    if (p && p.state && p.state.total > 0) return p.state.total
    const onShow = this.healthyTargets().find((t) => t.state && t.state.total > 0)
    return onShow ? onShow.state.total : 0
  }
  // Where the next relative move starts from: the optimistic intended slide if we have one (so
  // fast double-taps each advance), else the primary's last reported slide.
  navBase() { return this.intendedSlide != null ? this.intendedSlide : this.primarySlide() }

  // mode 'relative' = true Next (DEFAULT): the agent runs native "show next", so in-slide builds &
  //   animations play; every machine fires at the same gated time so identical decks stay locked.
  // mode 'synced' = absolute goto(current+1): fleet self-corrects to a slide number, but SKIPS builds.
  // 0.10.29: the rapid-tap debounce gap (ms). Companion fans every advance DIRECTLY to all machines (co-equal
  // leader), so it bypasses the agent's leader-side double-tap gate — the de-dup has to happen HERE, once, at
  // the single tap source, so every machine receives the identical de-duped stream and stays in sync. Use the
  // SLOWEST connected machine's MEASURED gap (so the fleet never outruns it), floored/capped to sane bounds.
  _advanceGapMs() {
    let g = 0
    for (const t of (this.targets || [])) { if (t.connected && t.state && t.state.advanceMinGapMs > g) g = t.state.advanceMinGapMs }
    if (!(g > 0)) g = 200
    return Math.max(120, Math.min(700, g))
  }
  _advanceEaten() {   // true = this tap should be dropped (came faster than the measured gap)
    const now = Date.now()
    if (now - (this._lastAdvFireAt || 0) < this._advanceGapMs()) return true
    this._lastAdvFireAt = now
    return false
  }
  next(mode = 'relative', machines = '') {
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'No machines matched the target filter'); return false }
    if (this._advanceEaten()) return false   // 0.10.29: eat a mashed tap so it never stacks/keeps advancing after release
    this._fireCue('go')   // instant lamp — don't wait for the state poll round-trip
    if (mode === 'relative') { this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null; this._noteManualStep(+1); return this.sendCommand({ action: 'next' }, only) }
    const base = this.navBase()
    if (base > 0) return this.gotoSlide(base + 1, machines)
    this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null
    return this.sendCommand({ action: 'next' }, only)
  }
  prev(mode = 'relative', machines = '') {
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'No machines matched the target filter'); return false }
    if (this._advanceEaten()) return false   // 0.10.29: eat a mashed tap (shared gate with next, like the agent's)
    this._fireCue('back')   // instant lamp
    if (mode === 'relative') { this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null; this._noteManualStep(-1); return this.sendCommand({ action: 'prev' }, only) }
    const base = this.navBase()
    if (base > 1) return this.gotoSlide(base - 1, machines)
    this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null
    return this.sendCommand({ action: 'prev' }, only)
  }
  jump(dir, count, machines = '') { this._fireCue(dir === 'back' ? 'back' : 'go'); const base = this.navBase() || 1; this.gotoSlide(dir === 'back' ? base - count : base + count, machines) }
  // build_step: "Nexts since the slide changed" — now tracked for MANUAL next/prev too (it was
  // previously only written by the builds-cue engine). Reset on every primary slide change.
  _noteManualStep(d) {
    if (this.cue.running) return                       // the cue engine owns build_step while rolling
    this._manualStep = Math.max(0, (this._manualStep || 0) + d)
    this.setVariableValues({ build_step: this._manualStep })
  }
  // OSCPoint-parity transport: media control + file load (gated like any command). Targetable subset.
  media(kind, machines = '') {
    // Drive KueSync's own video countdown off the operator's transport action (so it tracks even where
    // the OS won't report a playhead). Play/Pause toggles the clock; Jump-to-end zeroes it.
    if (kind === 'media_playpause') this._videoPlayPause()
    else if (kind === 'media_end' || kind === 'media_stop') this._videoFinish()
    return this.sendCommand({ action: kind }, this.resolveTargets(machines))
  }

  // ----------------------------- video clock (TRT / remaining / elapsed) -----------------------------
  // PLANNED length: a per-slide clip length the operator typed in the connection cue sheet (e.g. "7=120").
  _videoCueMs(slide) {
    const cue = parseCueSheet(this.config.cueSheet || '')
    const v = cue.map && cue.map[slide]
    return v > 0 ? Math.round(v * 1000) : 0
  }
  // Decide the TRT + how it's triggered for a slide: MEASURED (Windows) wins, else PLANNED (cue sheet).
  _resolveVideo(slide, p) {
    const measured = p ? Number(p.state.mediaTrtMs) : -1
    if (measured > 0) {
      let trig = String(p.state.mediaTrigger || '').toLowerCase()
      if (trig !== 'auto' && trig !== 'click') trig = 'manual'   // shape-click / unknown -> Play button
      return { trtMs: measured, source: 'measured', trigger: trig, playClick: Number(p.state.mediaPlayClick) }
    }
    const planned = this._videoCueMs(slide)
    if (planned > 0) return { trtMs: planned, source: 'planned', trigger: 'manual', playClick: -1 }
    return { trtMs: 0, source: 'none', trigger: 'none', playClick: -1 }
  }
  _videoArm(r) {
    const v = this.video
    v.trtMs = r.trtMs; v.source = r.source; v.trigger = r.trigger; v.playClick = r.playClick
    if (r.trtMs > 0 && r.trigger === 'auto') { v.clock = startClock(r.trtMs, Date.now()); v.armed = false }
    else if (r.trtMs > 0) { v.clock = { ...stopClock(), trtMs: r.trtMs }; v.armed = true }   // wait for the click / Play button
    else { v.clock = stopClock(); v.armed = false }
  }
  // ---------------- cue light (hardware-style) ----------------
  // Composite position (slide + build step) of the primary while a show runs; null when stopped. A rise
  // is a forward move (GREEN), a fall is a backward move (RED) — exactly like a DSAN cue light tracking
  // the presenter's clicker. Builds count, so an in-slide advance still flashes the lamp.
  _cuePos() {
    // A cue is a cue whether or not the show is in full-screen play — the lamp must flash when the
    // deck is advanced at a tech table too (state.slide tracks the editor as well as the show).
    const p = this.primary()
    if (!p || !p.state || !(p.state.slide > 0)) return null
    return (p.state.slide || 0) * 10000 + Math.max(Number(p.state.buildIndex) >= 0 ? Number(p.state.buildIndex) : 0, 0)
  }
  _observeCue() {
    const pos = this._cuePos()
    const prev = this.cueLight.lastPos
    // Skip the state-derived flash when an agent-pushed instant cue just fired (avoid doubles).
    const recentInstant = this.cueLight.instAt && (Date.now() - this.cueLight.instAt) < 700
    if (pos != null && prev != null && pos !== prev && !recentInstant) this._fireCue(pos > prev ? 'go' : 'back')
    this.cueLight.lastPos = pos
  }
  _fireCue(dir) {
    this.log('debug', `cue lamp -> ${dir}`)   // lamp activity is never invisible (v6.4.8)
    this.cueLight.state = dir
    this.cueLight.lastFireAt = Date.now()
    this.setVariableValues({ cue_signal: dir, cue_go: String(dir === 'go'), cue_back: String(dir === 'back') })
    // 0.12.0: a BARE checkFeedbacks() (re-evaluate everything) — NOT the typed checkFeedbacks('cue_light').
    // Field finding (log-confirmed): after a Companion relaunch the typed call re-rendered the preset PREVIEWS
    // but NOT the PLACED button instances (they stayed dark until a manual re-add), while a bare check reaches
    // placed buttons (that's why the on-connect re-render already uses bare). So the live lamp uses bare too.
    this.checkFeedbacks()
    if (this.cueLight.timer) { clearTimeout(this.cueLight.timer); this.cueLight.timer = null }
    if ((this.config.cueMode || 'flash') === 'flash') {
      const hold = Math.max(120, Number(this.config.cueHoldMs) || 800)
      this.cueLight.timer = setTimeout(() => {
        this.cueLight.state = 'idle'; this.cueLight.timer = null
        this.setVariableValues({ cue_signal: 'idle', cue_go: 'false', cue_back: 'false' })
        this.checkFeedbacks()
      }, hold)
    }
    // 'latch' mode: hold the colour until the next advance (no timer).
  }
  cueState() { return this.cueLight ? this.cueLight.state : 'idle' }

  _videoTick() {
    const p = this.primary()
    const slide = p ? (p.state.slide || 0) : 0
    const build = p ? Number(p.state.buildIndex) : -1
    const v = this.video
    if (slide !== v.lastSlide) {
      v.lastSlide = slide
      this._videoArm(this._resolveVideo(slide, p))
    } else if (v.trtMs === 0 && p) {
      // a measured length can arrive a tick after the slide change — pick it up once it's there
      const r = this._resolveVideo(slide, p)
      if (r.trtMs > 0) this._videoArm(r)
    } else if (v.armed && v.source === 'measured' && v.trigger === 'click' && v.playClick >= 0 && build >= v.playClick) {
      v.clock = startClock(v.trtMs, Date.now()); v.armed = false   // the firing click happened
    }
    const ro = clockReadout(v.clock, Date.now())
    const has = v.trtMs > 0
    const ticking = has || v.clock.active   // count-up stopwatch: elapsed is live even without a TRT
    this.setVariableValues({
      media_trt: has ? fmtClock(ro.trtMs) : '—',
      media_remaining: has ? fmtClock(ro.remainingMs) : '—',
      media_elapsed: ticking ? fmtClock(ro.elapsedMs) : '—',
      media_remaining_secs: Math.round(ro.remainingMs / 1000),
      media_playing: String(!!v.clock.playing),
      media_has: String(has),
      media_armed: String(!!v.armed),
      media_time_source: has ? sourceLabel(v.source) : '—',
      media_source_short: has ? (v.source === 'measured' ? 'measured' : v.source === 'planned' ? 'planned' : 'n/a') : '—',
    })
    this.checkFeedbacks('media_ending')
  }
  _videoPlayPause() {
    const v = this.video
    if (v.trtMs <= 0) {
      // No known clip length (Mac without a cue-sheet entry): run a COUNT-UP stopwatch anyway, so
      // ELAPSED is always live from the operator's Play click even when TRT can't be measured.
      if (!v.clock.active) { v.clock = startClock(0, Date.now()); v.source = 'stopwatch' }
      else v.clock = v.clock.playing ? pauseClock(v.clock, Date.now()) : resumeClock(v.clock, Date.now())
      return
    }
    if (v.armed || !v.clock.active) { v.clock = startClock(v.trtMs, Date.now()); v.armed = false; return }
    v.clock = v.clock.playing ? pauseClock(v.clock, Date.now()) : resumeClock(v.clock, Date.now())
  }
  _videoFinish() {
    const v = this.video
    if (v.trtMs > 0) { v.clock = finishClock({ ...v.clock, trtMs: v.trtMs }, Date.now()); v.armed = false }
  }
  // For the "video ending soon" feedback: seconds left, and whether a video is active/playing.
  videoRemainingSecs() { return Math.round(clockReadout(this.video.clock, Date.now()).remainingMs / 1000) }
  videoActive() { return this.video.trtMs > 0 && !!this.video.clock.playing }
  openFile(path, machines = '', password = '') { const c = { action: 'open', path: String(path || '') }; if (password) c.password = String(password); return this.sendCommand(c, this.resolveTargets(machines)) }
  // Resync snaps everyone to a slide (or, blank, to the primary's actual reported slide).
  // Resync: an explicit slide jumps the fleet there (RESYNC ALL with a number). Blank / SYNC NOW aligns to
  // the fleet's CURRENT slide. 0.9.6: if nothing is in a show yet, do NOTHING (don't yank the fleet to slide 1).
  resync(slide) {
    if (slide > 0) return this.gotoSlide(slide)
    const n = this.primarySlide()
    if (n > 0) return this.gotoSlide(n)
    this.log('info', 'sync now: no machine is in a show yet — nothing to align to')
  }
  last() { const total = this.primaryTotal(); if (total > 0) return this.gotoSlide(total); this.lastCmdAt = Date.now(); return this.sendCommand({ action: 'last' }) }

  gotoSlide(n, machines = '') {
    const only = this.resolveTargets(machines)
    const total = this.primaryTotal()
    if (total) n = Math.min(n, total)
    n = Math.max(1, n)
    // Instant lamp on goto too — direction is known from where the fleet is heading vs now.
    const base = this.navBase()
    if (base > 0 && n !== base) this._fireCue(n > base ? 'go' : 'back')
    if (!only) this.intendedSlide = n   // only track fleet-wide intent when driving the WHOLE fleet
    this.lastCmdAt = Date.now()
    return this.sendCommand({ action: 'goto', slide: n }, only)
  }

  // ----------------------------- cue engine (drives all machines) -----------------------------
  runCue({ fromStart = false, loop = false, defaultDwell, perSlide, builds = false } = {}) {
    this.cancelCue()
    const cue = buildCue({ defaultDwell, perSlideText: perSlide, fallbackText: this.config.cueSheet })
    if (builds) return this.runCueBuilds({ fromStart, loop, cue })
    const p = this.primary()
    let slide = fromStart ? 1 : (p ? p.state.slide || 1 : 1)
    this.cue.running = true; this.cue.paused = false; this.cue.loop = loop
    this.gotoSlide(slide)
    this.cue.targetSlide = slide
    // TIMESTAMP-driven dwell (A7): the advance fires when the wall-clock deadline passes, not after
    // N accumulated ticks — so long cues don't drift with timer granularity, and pause is exact
    // (pause stamps the moment; resume pushes the deadline forward by the paused span).
    this.cue.deadlineAt = Date.now() + dwellFor(cue, slide) * 1000
    this.cue.dwellRemaining = dwellFor(cue, slide)
    this.updateUi()

    this.cue.ticker = setInterval(() => {
      if (this.cue.paused) return
      const remainMs = this.cue.deadlineAt - Date.now()
      const remainS = Math.max(0, Math.ceil(remainMs / 1000))
      if (remainS !== this.cue.dwellRemaining) { this.cue.dwellRemaining = remainS; this.setVariableValues({ cue_dwell_remaining: remainS }) }
      if (remainMs > 0) return
      const total = this.primary() ? this.primary().state.total : 0
      const nextSlide = nextCueSlide(this.cue.targetSlide || (this.primary()?.state.slide ?? 0), total, this.cue.loop)
      if (nextSlide == null) return this.cancelCue()
      this.gotoSlide(nextSlide)
      this.cue.targetSlide = nextSlide
      this.cue.deadlineAt = Date.now() + dwellFor(cue, nextSlide) * 1000
      this.cue.dwellRemaining = dwellFor(cue, nextSlide)
      this.updateUi()
    }, 250)
  }
  // Bring the deck up FULL SCREEN at slide 1 on every machine and HOLD — do not auto-advance.
  // Uses the agent's robust restart (stop → set current slide 1 → start), gated, so it reliably
  // (re)enters the show at slide 1 even from the editor. GO DECK = "arm / standby"; press LOOP DECK
  // (or RUN CUE) afterwards to begin advancing.
  goDeck() {
    this.cancelCue()
    this.intendedSlide = 1
    this.lastCmdAt = Date.now()
    this.sendCommand({ action: 'restart' })
  }

  // END SHOW: stop any roll/cue AND exit the slideshow on every machine (back to the editor). One
  // button to take the whole fleet down cleanly at the end of a show.
  endShow() {
    this.cancelCue()
    this.intendedSlide = null
    this.lastCmdAt = Date.now()
    this.sendCommand({ action: 'stop' })
  }

  // Build-aware auto-play: sends one SYNCED native Next per step, so in-slide builds & animations
  // play and all machines step together (identical decks).
  //
  // PER-SLIDE TIMING *WITH* BUILDS: the hold before each step is keyed off the slide the fleet is
  // actually displaying — dwellFor(cue, currentSlide) — so a custom time (e.g. slide 7 = 120s for an
  // embedded video) holds that slide while every other slide still advances/builds at the default.
  // Timing is measured from the last step (a timestamp) so it survives pause and is unaffected by the
  // 1s tick granularity.
  //
  // BUILD-STEP TRACKING: the module issues every advance, so it knows the build position by counting
  // Nexts since the slide number last changed (Keynote can't report it; we drive it). Exposed as
  // `build_step` and reset on each slide change.
  //
  // LOOPING uses KEYNOTE'S OWN "Loop slideshow" setting; if it's off we detect the exit (playing→
  // false) and fall back to a gated restart. NON-LOOP plays through once and stops at the end.
  runCueBuilds({ fromStart = false, loop = false, cue } = {}) {
    this.cue.running = true; this.cue.paused = false; this.cue.loop = loop; this.cue.builds = true
    this.cue.cueSheet = cue || { default: 8, map: {} }
    this.intendedSlide = null
    if (fromStart) {
      const playing = !!(this.primary() && this.primary().state && this.primary().state.playing)
      if (playing) this.gotoSlide(1)
      else { this.intendedSlide = 1; this.lastCmdAt = Date.now(); this.sendCommand({ action: 'restart' }) }
    }
    this._buildStarted = false
    this._lastStepAt = Date.now()
    this._buildStep = 0
    this._curStepSlide = this.primarySlide()
    this.updateUi()

    this.cue.ticker = setInterval(() => {
      if (this.cue.paused) { this._lastStepAt += 1000; return }   // freeze the dwell while paused
      const playing = !!(this.primary() && this.primary().state && this.primary().state.playing)
      if (playing) this._buildStarted = true

      // Show no longer playing after it had started.
      if (this._buildStarted && !playing) {
        if (!loop) return this.cancelCue()           // non-loop: deck ended → stop
        this.intendedSlide = 1; this.lastCmdAt = Date.now()
        this.sendCommand({ action: 'restart' })      // loop fallback (deck's own loop is off)
        this._buildStarted = false; this._lastStepAt = Date.now()
        this._buildStep = 0; this.updateUi()
        return
      }

      const slide = this.primarySlide()
      if (slide !== this._curStepSlide) { this._curStepSlide = slide; this._buildStep = 0 } // new slide
      const dwell = Math.max(1, dwellFor(this.cue.cueSheet, slide))   // seconds for the CURRENT slide
      const elapsed = Math.floor((Date.now() - this._lastStepAt) / 1000)
      this.setVariableValues({ cue_dwell_remaining: Math.max(0, dwell - elapsed), build_step: this._buildStep })
      if (Date.now() - this._lastStepAt < dwell * 1000) return

      this.sendCommand({ action: 'next' })   // one synced step (build/animation), Keynote loops at end
      this._buildStep += 1
      this._lastStepAt = Date.now()
      this.updateUi()
    }, 1000)
  }

  pauseCue() { if (this.cue.running) { this.cue.paused = true; this.cue.pausedAt = Date.now(); this.updateUi() } }
  resumeCue() {
    if (!this.cue.running) return
    // Exact resume: the deadline moves forward by exactly how long we sat paused.
    if (this.cue.paused && this.cue.pausedAt && this.cue.deadlineAt) this.cue.deadlineAt += Date.now() - this.cue.pausedAt
    this.cue.paused = false; this.cue.pausedAt = 0; this._lastStepAt = Date.now(); this.updateUi()
  }
  cancelCue() {
    if (this.cue.ticker) clearInterval(this.cue.ticker)
    this.cue = { running: false, paused: false, ticker: null, dwellRemaining: 0, targetSlide: 0, loop: false }
    this.setVariableValues({ build_step: 0 })
    this.updateUi()
  }

  // ----------------------------- UI -----------------------------
  updateUi() {
    updateVariables(this)
    this.republishFollowerUi()   // refresh the Follower picker if the fleet or a device name changed (sig-gated)
    // CUE-LIGHT SELF-CORRECTION (so the lamp never needs a manual reload between uses):
    //  (a) Watchdog — if a flash got stuck lit because its reset timer was lost on a reconnect/blip,
    //      force it back to idle once the hold window has clearly passed. Without this the 1 Hz
    //      re-assert below would keep painting the STUCK colour forever.
    //  (b) Position fallback — re-derive a missed advance from the fleet's current slide/build, so the
    //      lamp still flashes within ~1 s even if BOTH the agent cue event and the state-change tick
    //      were missed. Dedupe is handled by lastPos + the 700 ms instant-cue suppression.
    try {
      if ((this.config.cueMode || 'flash') === 'flash' && this.cueLight.state !== 'idle') {
        const hold = Math.max(120, Number(this.config.cueHoldMs) || 800)
        if (Date.now() - (this.cueLight.lastFireAt || 0) > hold + 600) {
          if (this.cueLight.timer) { clearTimeout(this.cueLight.timer); this.cueLight.timer = null }
          this.cueLight.state = 'idle'
          this.setVariableValues({ cue_signal: 'idle', cue_go: 'false', cue_back: 'false' })
        }
      }
      this._observeCue()
    } catch {}
    // Every feedback in this once-a-second sweep SELF-HEALS: if Companion ever drops a button's paint
    // (a page redraw, a surface reconnect, a blip), the next tick re-asserts it. v0.7.9: `cue_light` and
    // `fleet_version` were the only visible feedbacks NOT in this list, so they alone went stale and had
    // to be reloaded by hand while everything else recovered on its own. The crisp cue FLASH still comes
    // from _fireCue's immediate checkFeedbacks('cue_light'); re-asserting here only keeps the lamp's
    // RESTING state correct (idle, or a latched colour) so it never gets stuck needing a manual reload.
    this.checkFeedbacks('connected', 'all_connected', 'all_healthy', 'fleet_in_sync', 'playing', 'on_slide',
      'cue_running', 'license_state', 'leader_known', 'cue_light', 'fleet_version', 'follower_active', 'follower_slidedriver')
  }
  refreshLicenseUi() {
    const ent = this.license.evaluate()
    if (ent.state === 'trial') this.log('info', `KueSync trial: ${ent.trialDaysLeft} day(s) left`)
    else if (ent.state === 'licensed') this.log('info', `KueSync licensed to ${ent.licensedTo}`)
    this.updateStatusAggregate()
    this.updateUi()
  }
}

runEntrypoint(KueSyncInstance, UpgradeScripts)
