// control.js — the pgh control surface, which is what makes OTA updates reach a RUNNING daemon.
//
// WHY THIS EXISTS (2026-10-12). favela-peer had no control surface, so the sidecar had nowhere to
// push `release-ready`: an update installed (staged, because the daemon was running) and the daemon
// kept running the old code until someone restarted it by hand. 0.0.61 was published and installed
// while tabertron and stortron both kept running 0.0.57.
//
// THE OTA CONTRACT (pgh-sidecar lib/index.js `_notifyAppsOfNewReleases`; the same contract every
// pgh daemon with a control surface follows):
// 1. The sidecar does the FULL install: tree, dep closure, materialize. A running app's install is
// staged as <name>.next.
// 2. It pushes `release-ready` to the udsPath the app REGISTERED with `app-register`. An app that
// never registered is never told.
// 3. We answer ok FIRST, then shut down and exit 75.
// 4. The supervisor applies the staged tree and respawns immediately (75 skips its crash backoff).
//
// ⚠ This is NOT favela.sock. That socket speaks favela's own line-JSON RPC for the CLI and the
// surface; the sidecar speaks hyper-svc. Two protocols, two sockets.
//
// FAILS SOFT. Holding and serving feeds matters more than being remotely manageable: if the socket
// cannot bind, the daemon logs it and keeps running. Registration is best-effort too, because a
// standalone `bare index.js` has no sidecar at all.
import fs from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import process from '#node:process'
export function sidecarSockPath () {
return process.env.PGH_SIDECAR_SOCK || join(os.homedir(), '.pgh', 'svc.sock')
}
async function registerWithSidecar ({ udsPath, name, version }) {
const sidecarSock = sidecarSockPath()
if (!fs.existsSync(sidecarSock)) return false
try {
const { Client } = await import('hyper-svc')
const c = await Client.connectUds(sidecarSock)
try { await c.call('app-register', { name, udsPath, pid: process.pid, runtime: 'bare', version }) } finally { await c.close() }
return true
} catch {
return false
}
}
// Register now, then again on an interval: the sidecar keeps its app registry IN MEMORY, so a
// sidecar restart silently drops us and the next release is never pushed. The sidecar socket can
// also appear after us (its cold start indexes a large store), which the interval covers too.
function maintainRegistration ({ udsPath, name, version, intervalMs = 30000 }) {
registerWithSidecar({ udsPath, name, version })
const timer = setInterval(() => { registerWithSidecar({ udsPath, name, version }) }, intervalMs)
if (timer.unref) timer.unref()
return () => clearInterval(timer)
}
/**
* Stand up the control socket and register it with the sidecar.
*
* @param {object} opts
* @param {string} opts.udsPath
* @param {string} opts.name
* @param {string} [opts.version]
* @param {Function} [opts.status] returns the `status` reply
* @param {Function} opts.onRelease called with the version AFTER the reply is queued; owns the
* shutdown and the exit code
* @param {Function} [opts.log]
* @returns {Promise<{close: Function}|null>} null when the surface could not come up
*/
export async function startControl ({ udsPath, name, version = null, status = null, onRelease, log = defaultLog }) {
try {
const { Service } = await import('hyper-svc')
// A stale socket from a killed process blocks bind. index.js only gets here after refusing to
// start beside a live daemon, so an existing file is orphaned.
try { if (fs.existsSync(udsPath)) fs.unlinkSync(udsPath) } catch {}
const svc = new Service({ name, uds: udsPath })
svc.handle('status', async () => (status ? status() : { ok: true, name, version }))
svc.handle('release-ready', async (ctx, params = {}) => {
const ver = (params && params.version) || '?'
log({ msg: 'ota: release-ready — sidecar installed; exiting 75 for supervisor respawn', version: ver })
// After the reply is queued: exiting first drops the connection, and the sidecar cannot tell
// "accepted, respawning" from "crashed".
setImmediate(() => { onRelease(ver) })
return { ok: true, willRespawn: true }
})
await svc.listen()
const stop = maintainRegistration({ udsPath, name, version })
log({ msg: 'control surface up — OTA respawn armed', uds: udsPath })
return {
async close () {
stop()
try { await svc.close() } catch {}
try { fs.unlinkSync(udsPath) } catch {}
}
}
} catch (err) {
log({ msg: 'control surface unavailable (continuing to serve)', error: (err && err.message) || String(err) })
return null
}
}
function defaultLog (o) { console.log(JSON.stringify(o)) }
export default startControl