Loading files
6aee22971This file is pinned to the selected release. Download the original to open it in your design tool.
// The message layer: what the daemon and the dial SAY to each other, on top of the bytes serial.ts moves.
//
// Written twice — here and in apps/esp32-circle/main/cable_client.c of the autonomous-code repository —
// with no shared code, because the two halves ship from different repositories. The framing underneath
// agrees by shared vectors; this layer agrees by docs/cable-protocol.md and by being small enough to read
// in one sitting.
//
// THE VOCABULARY IS THE PRODUCT'S: machine → agent → session. The machine is this computer, the agents
// are what the registry holds, and a session is one conversation underneath an agent.
//
// Three rules here were paid for on real hardware and are not style:
//
// 1. ANSWER EVERY `hello`, RE-ATTACH ONLY FOR A DIAL NOT ALREADY GREETED. The dial greets on a cadence
// because it has no port-open event to wait on. Attaching means "this device knows nothing" and
// pushes the whole state again; doing that on every greeting re-sends everything every 15 seconds.
//
// 2. PING EVEN WHEN IDLE, AND REOPEN THE PORT ON SILENCE. After the dial reboots, macOS hands back a
// `/dev/cu.usbmodem*` node with the same name, the same inode and the same device numbers. Writes to
// the old handle keep succeeding, the read never completes, and nothing raises. Without a heartbeat
// this daemon would talk to a dead file for as long as anyone watched.
//
// 3. SAY NOTHING WHEN NOTHING CHANGED. That is what keeps the link idle during a long turn, and it is
// why rule 2 has to exist at all.
import { appendFile } from 'node:fs/promises'
import { CableDecoder, CableType, encodeCableFrame } from './cableFrame.js'
import { FirmwareTransfer } from './fwPush.js'
import { SerialLink, findDialPort } from './serial.js'
/** Bumped when the VOCABULARY changes. Separate from the frame version, which is the envelope. */
export const CABLE_PROTO_VERSION = 3 // 3: + question.close (a question answered on another client)
const PING_EVERY_MS = 5_000
/** No bytes of any kind for this long → the handle is dead. Longer than the dial's own 15 s window, so a
* single late message cannot trip both sides at once and have each conclude the other left. */
const SILENCE_MS = 20_000
const REOPEN_EVERY_MS = 2_000
// How long one attempt at opening the port may take before it is abandoned.
//
// ⚠️ THIS IS WHAT KEEPS THE `opening` GUARD FROM BECOMING A PERMANENT STALL. That guard exists to stop
// two opens racing (which stranded read loops on one tty and shredded the stream), but a guard released
// only when the attempt finishes is a guard held forever by an attempt that never does. Opening spawns
// `stty` against a device that may have just re-enumerated mid-flash; if that call hangs, the dial is
// gone until the daemon is restarted — no error, no retry, no log line saying why. Observed once: the
// link closed at 14:14 and nothing tried again for twelve minutes.
//
// TWENTY SECONDS, RAISED FROM EIGHT, because eight was under the cost of the thing it was timing.
// Measured on a dial that had just re-enumerated after a flash — the whole reason this budget mentions
// re-enumeration in the first place:
//
// 22:26:28.077 cannot open the dial: open timed out after 8000ms ← gave up
// 22:26:29.660 dial 28:84:85:90:5F:78 on fw 0.0.58 proto 3 ← opened, 1.58s late
// 22:26:29.661 machines → 3 · agents → 8 (attach) ← FULL handshake, succeeded
// 22:26:29.662 closed (abandoned — open timed out) ← and thrown away
//
// The open costs about 9.6s there, every time, so the deadline never once let a working link live: the
// cycle repeated on an exact 8-second beat for as long as anyone watched, and the dial sat on "Not
// connected" while the daemon greeted it successfully every eight seconds and hung up on it. That is
// the worst shape a timeout can take — not a slow failure, a discarded success.
//
// The stall this guards against is a `stty` that never returns, which twenty seconds catches as surely
// as eight. What it costs is the wait after a replug, and the honest number for that is the ~10s the
// open actually takes, not the 8 it was being given.
const OPEN_TIMEOUT_MS = 20_000
/** What this dial must call itself. A greeting that does not say exactly this is another product's. */
const CABLE_PRODUCT = 'harness'
// How long a port may deliver BYTES WITHOUT A SINGLE DECODABLE FRAME before it is written off as another
// product's dial.
//
// The magic (cableFrame.ts) makes the sibling product's frames undecodable here, which is the point — but
// it also means a foreign dial looks exactly like a chatty port that says nothing we understand. Left
// alone we would hold that port open forever, and two daemons reading one tty is not a stalemate: each
// takes a share of the other's bytes, so frames arrive interleaved and BOTH links fail. Measured on this
// hardware: five readers on one stream cost 18 of 22 greetings.
//
// Generous on purpose. Our own dial is silent-then-noisy across a reboot — the ROM and the bootloader
// write plain text to this same wire before the application starts framing anything — so a short window
// would evict a dial that was merely booting.
const FOREIGN_AFTER_MS = 12_000
// Most firmware writes one dial may take in an hour. A real release is one; anything repeating is two
// pieces of software disagreeing about who owns the board, and the dial pays for that in erase cycles.
const FW_WRITES_PER_HOUR = 3
/** How often the machine list is re-read. Slower than the agent list because it is an HTTP read, not a
* map lookup — and because a machine appearing is not something anyone is waiting on a stopwatch for. */
const MACHINES_POLL_MS = 15_000
/**
* A programmatic focus write may be followed by the dial reporting the tile it was painting just before
* that write. Real hardware delivered that stale report 23 ms after the machine switch completed; keep
* a short transaction open until the commanded tile echoes, without making the dial feel unresponsive.
*/
/**
* How long a focus COMMANDED by the window keeps the dial's own reports out.
*
* Deliberately SHORT, and it was briefly 4s — which broke the thing it was meant to protect. The echo it
* guards against is gone at the source now: a dial told where to look moves through code, and a move made
* by code is not reported at all (carousel_goto in ui_screens.c). What a long window does instead is
* swallow a REAL swipe made in the seconds after any click in the window — the two screens then sit on
* different agents with nothing to correct them, which is exactly the report that followed.
*
* So it stays only as long as a stale echo from an OLDER firmware could take, and every report it drops
* now says so in the log.
*/
const APP_FOCUS_SETTLE_MS = 750
/**
* How long after an app-driven switch begins its own repaint can still arrive.
*
* The switch itself is NOT the measure, and using it as one is what broke: selecting a remote machine
* takes seconds — ten, measured — and while `drivingAppFocus` was true every report was discarded, so a
* swipe made during it vanished and the two screens ended up on different agents.
*
* A repaint answers the command almost at once; a hand does not. So the guard lasts as long as the first,
* not as long as the whole errand.
*/
const APP_SWITCH_REPAINT_MS = 1_000
/** What a `voice.begin` without an `sr` is assumed to be — firmware old enough not to say. */
const DEFAULT_VOICE_RATE = 16_000
/** A voice turn longer than this is a stuck dial, not a person talking. 16 kHz mono 16-bit ≈ 32 KB/s. */
const VOICE_MAX_BYTES = 10 * 60 * 32_000
export interface CableAgent {
id: string
name: string
engine?: string
model?: string
effort?: string
/**
* An agent the dial KNOWS but does not walk to.
*
* The carousel is built around the window's tiles, and an agent sitting
* between them in list order is off neither edge — see `deskRing`. It is
* still sent: the dial counts its agents on the overview and lists them in
* the pull-down switcher, and a dial that says "5 agents" to someone who has
* eleven is simply wrong. What it is left out of is the walk.
*/
offRing?: boolean
/**
* The machine this agent lives on, and that machine's name.
*
* THE CAROUSEL IS NO LONGER ONE MACHINE'S. Every agent on every machine is on it at once, so an agent
* that does not say where it lives cannot be driven: the daemon routes each turn, stop and answer by
* this id, and the dial prints the name on the switcher's second line where the engine used to be.
*
* Optional because the WIRE is: a firmware that predates the field simply does not draw it.
*/
machineId?: string
machine?: string
}
/**
* One row of the dial's machine wheel.
*
* `state` is the ONLY liveness word on the wire, deliberately as one tri-value rather than an `online`
* boolean beside a `known` one: one field cannot contradict itself, two can — and would, the first time a
* list refresh fails halfway through.
*/
export interface CableMachine {
id: string
name: string
state: 'ready' | 'offline' | 'unknown' | 'needs-link'
/** Exactly one row carries this: the computer at the other end of the cable. */
local: boolean
}
/**
* One of the window's swarms — a named group of agents arranged on one grid. The window owns them
* entirely (they live in its state file and nowhere else); the daemon relays the list so the dial can
* name the one on screen and offer the others, and relays a pick straight back.
*/
export interface CableSwarm {
id: string
name: string
/** How many agents it holds — the dial draws the count, never the members. */
agents: number
}
/** The window's swarms as it last described them, or null while no window is connected. */
export interface AppSwarms {
active: string
swarms: Array<{ id: string; name: string; agentIds: string[] }>
}
/** Why the list is as short as it is. The dial renders this, instead of drawing an empty wheel. */
export type CableMachineSource = 'backend' | 'local' | 'signed-out'
export type { WindowRoute } from './windowRoute.js'
import type { WindowRoute } from './windowRoute.js'
export interface RouteDecision {
agentId: string
confidence: number
reason: string
}
/**
* Everything the session needs from the rest of the daemon. An interface rather than a direct import so
* the protocol can be tested against a fake — the alternative is a suite that needs tmux, a microphone
* and a network to prove that `hello` gets a `welcome`.
*/
export interface CableHost {
/** The computer at the other end of the cable — its identity, not "the" machine's. */
localMachine(): { id: string; name: string }
/** Every machine the owner has, local row included. Never rejects: `source` explains a short list. */
listMachines(): Promise<{ machines: CableMachine[]; source: CableMachineSource }>
/** Which one the agent list currently belongs to. */
selectedMachine(): string
/**
* Switch. Resolves to a discriminated result and NEVER throws — a refusal is data here, the same way
* `firmwareFor()` returning null is. `code` is the daemon's; `message` is shown to a person verbatim,
* so the dial needs no table for a set it does not own.
*/
selectMachine(machineId: string): Promise<{ ok: true } | { ok: false; code: string; message: string }>
/** The window's swarms and which is on screen. Empty with no window: the dial then draws no swarm line. */
listSwarms(): { selected: string; swarms: CableSwarm[] }
/** The dial picked a swarm. Relayed to the window, which switches and re-describes its desk. */
selectSwarm(swarmId: string): void
appName(): string
voiceLang(): string
listAgents(): Promise<CableAgent[]>
sendTurn(agentId: string, text: string): void
stopTurn(agentId: string): void
/**
* The user answered a `question`. `answers` is keyed by the QUESTION keys the daemon itself asked with
* and travels verbatim — re-deriving it, or keying it by the requestId, is how a rename at either end
* becomes an answer nobody gave.
*/
answer(agentId: string, requestId: string, answers: Record<string, string>): void
focus(agentId: string): void
/**
* "Put this one in front of me" — a NOTIFICATION was tapped.
*
* A different verb from [focus], and the difference is the whole point: focus
* says where the eye is and the window moves a tile to match, while this asks
* for a tile of its own. A turn that just finished is a new thing to look at,
* not a replacement for whatever the person was already watching.
*/
openAgent(agentId: string): void
/**
* Does this daemon's own agent list hold that id?
*
* Only for saying so. A `focus` for an agent the dial has no tile for lands on nothing, and from the
* outside that is indistinguishable from the dial ignoring the window — which is exactly the report
* that took a morning to explain. Optional, so a host that cannot answer simply says nothing.
*/
knows?(agentId: string): boolean
/** A finger moving on the dial's glass, on its way to whatever window is open on this computer. */
scrolled(phase: 'down' | 'move' | 'up', dy: number, velocity: number): void
updateAgent(agentId: string, model?: string, effort?: string): void
/** PCM is 16-bit mono at `sampleRate`; the daemon owns the credential this needs. */
transcribe(pcm: Buffer, sampleRate: number, lang: string): Promise<string>
/** Which agent the words belong to. Scored locally first; only a genuine tie should cost a network call. */
route(transcript: string, agents: CableAgent[]): Promise<RouteDecision>
/**
* Offer the words to the desktop window FIRST, and let its palette decide.
*
* The window's ⌘B router is the one that gets looked after — fifteen candidates in rail order, recaps
* cut to sixty characters, a twenty-second budget, and a picker when it is not sure. [route] is the
* same idea a version behind, so this is asked first and that is the fallback rather than the default.
*
* Optional: a host with no window to ask simply omits it, and voice behaves exactly as it always has.
*/
routeInWindow?(text: string, cmd?: string): Promise<WindowRoute>
/** The runtime model/effort catalog for one agent, as opaque profile ids the dial groups and shows. */
listModels(agentId: string): Promise<string[]>
/** One agent's last turn summaries, newest first — what a reattached dial needs to redraw its tiles. */
recentSummaries(agentId: string): Promise<Array<{ recap: string; text: string }>>
/**
* The image to offer a dial running `runningVersion`, or null for "nothing to do" — which covers a
* dial that is current, a dev build that must not be touched, and an unreachable manifest.
*/
firmwareFor?(runningVersion: string): Promise<{ version: string; image: Buffer; sha256: string } | null>
/**
* A dial greeted us, or the port went away.
*
* The daemon holds its cloud lane ON THE DIAL'S BEHALF — nothing else in this process uses it. So with
* no dial there is nobody to hold it for, and holding it anyway leaves the account showing a device
* that is attached to a machine while sitting unplugged in a drawer.
*/
onDialAttached?(): void
onDialGone?(): void
/**
* The dial as a window would draw it: there or not, on which firmware, and whether an update is
* going over the cable right now. Fired on every change and never on a keepalive — the window
* shows this in its rail, and a rail that redraws four times a minute to say "still here" is a rail
* nobody can afford. What is NOT here: anything a hand did (that is `focused`/`opened`/`scrolled`).
*/
onDialStatus?(status: DialStatus): void
log(line: string): void
}
/** What a window needs to draw the device row. `updating` names the version on its way over. */
export interface DialStatus {
attached: boolean
fw?: string
updating?: string
}
interface Message {
t: string
[key: string]: unknown
}
/**
* The part of a port this layer uses. An interface rather than the class, so the protocol can be driven
* by a loopback in tests — proving that `hello` gets a `welcome` should not require a dial on the desk.
*/
export interface CablePort {
readonly path: string
readonly isOpen: boolean
write(bytes: Uint8Array): Promise<void>
close(why?: string): Promise<void>
}
/** Open the dial's port, or null when it is not there. Both are ordinary answers. */
export type PortOpener = (
onData: (chunk: Buffer) => void,
onClosed: (why: string) => void,
) => Promise<CablePort | null>
/** The real one: find the tty by USB id, open it raw. */
export const openDialPort: PortOpener = async (onData, onClosed) => {
const port = await findDialPort()
if (!port) return null
return SerialLink.open(port.path, onData, onClosed)
}
export class CableSession {
private link: CablePort | null = null
private decoder = new CableDecoder()
private timer: NodeJS.Timeout | null = null
private greetedMac: string | null = null
private greetedFw: string | null = null
private lastRx = 0
private stopped = false
/** What the dial was last told the agent list is. Empty = it has been told nothing. */
private lastAgentsKey = ''
/** Bytes and frames seen since this port opened — the evidence for the foreign-dial verdict. */
private bytesSinceOpen = 0
private framesSinceOpen = 0
/** Ports written off as another product's, and when it becomes worth looking again. */
private foreignPort: string | null = null
private foreignRetryAt = 0
/** Which images have already been written to which dial, and when. Survives the port, unlike `offered`. */
private readonly written = new Set<string>()
private readonly writeLog = new Map<string, number[]>()
/**
* THE agent both screens are meant to be on. One memory, written by whichever side moved last.
*
* There were two — where the dial was, and what the window last said — and they drifted apart in the
* one case that matters most: when the WINDOW follows the DIAL, `followApp` returns early (the dial is
* already there), so the window's memory was never updated and kept an older agent. Anything that then
* re-asserted "where the window is" sent that stale agent, the dial jumped back to it, and the window's
* next report pulled it forward again. Measured on the real dial as a tile going 3 → 4 → 3 → 4.
*
* A single field cannot disagree with itself. Both sides write it on a real move, the last write wins,
* and it is the only thing the daemon ever re-asserts.
*/
private desiredFocus = ''
/**
* App-driven machine/focus changes are one transaction. Without this queue, two quick desktop clicks
* can interleave their machine lists and let the older click focus last.
*/
private appFocusTail: Promise<void> = Promise.resolve()
private appFocusGeneration = 0
/** When the app-driven switch began — see APP_SWITCH_REPAINT_MS. */
private drivingSince = 0
/** While the app is rebuilding the dial's machine/list state, focus reports describe that rebuild. */
private drivingAppFocus = false
/** The dial may echo a focus command after its write promise resolves; consume that one echo. */
private expectedAppFocusEcho = ''
private expectedAppFocusEchoUntil = 0
/** The same, for the machine wheel. Both reset together on any new port — see `tryOpen`. */
private lastMachinesKey = ''
/** Throttle for the machine list, which costs an HTTP read where the agent list costs a map lookup. */
private machinesAt = 0
/**
* Serialises every STREAMED push (begin / rows / end).
*
* ⚠️ NOT a precaution. `onMessage` is fired from the decoder callback and never awaited, so a dial that
* greets and then asks for both lists has three pushes in flight at once — each awaiting a write
* between every row. Their frames interleave, and the far end sees `begin, begin, 8 rows, end, end`:
* it stages one machine list twice over and reports eight machines where the daemon sent four.
*
* Measured on hardware 2026-08-25, first plug-in of the proto-2 firmware. The dial's own hash gate does
* not help here — it faithfully gates a list that was already shredded on the way in.
*/
private pushChain: Promise<void> = Promise.resolve()
/** Run `body` after every push already queued, and before any queued later. */
private queued(body: () => Promise<void>): Promise<void> {
const next = this.pushChain.then(body)
// The tail swallows so one failed push cannot strand the ones behind it; the caller still sees it.
this.pushChain = next.catch(() => {})
return next
}
/** A firmware transfer in flight, and the versions already tried this session. */
private transfer: FirmwareTransfer | null = null
/** `<mac>:<version>` already offered on this port. The durable, cross-port guard is `mayOffer`. */
private offered = new Set<string>()
/** Voice capture in flight: PCM chunks as they arrive, plus what `voice.begin` said about them. */
private voice: { agentId?: string; cmd?: string; lang: string; rate: number; chunks: Buffer[]; bytes: number } | null = null
constructor(
private readonly host: CableHost,
/** Where framed device logs are appended. The dial has one USB port, so this file is its console. */
private readonly logPath: string,
private readonly openPort: PortOpener = openDialPort,
) {}
get isConnected(): boolean {
return this.link?.isOpen === true && this.greetedMac !== null
}
start(): void {
this.stopped = false
this.timer = setInterval(() => void this.tick(), 1_000)
void this.tick()
}
async stop(): Promise<void> {
this.stopped = true
if (this.timer) clearInterval(this.timer)
this.timer = null
await this.link?.close('daemon stopping')
this.link = null
}
// ── port lifecycle ────────────────────────────────────────────────────────────────────────────────
private async tick(): Promise<void> {
if (this.stopped) return
if (!this.link?.isOpen) {
await this.tryOpen()
return
}
// BYTES BUT NO FRAMES = SOMEBODY ELSE'S DIAL. Nothing we can read has arrived, and something is
// plainly talking — which is what the sibling product's panel looks like from here now that the two
// framings no longer overlap. Holding the port would leave both daemons reading one tty and taking
// turns stealing each other's bytes, so this side lets go and remembers not to come back for it.
if (this.link?.isOpen
&& this.framesSinceOpen === 0
&& this.bytesSinceOpen > 0
&& Date.now() - this.openAt > FOREIGN_AFTER_MS) {
this.foreignPort = this.link.path
// Long enough not to fight over it, short enough to notice a swap. In practice the other daemon
// takes the port the moment this one lets go and a tty is exclusive on macOS, so the next probes
// fail to open rather than steal it back — this interval only decides how soon we would find OUR
// dial if the user unplugged theirs and plugged ours into the same socket.
this.foreignRetryAt = Date.now() + 60_000
this.host.log(`cable: ${this.link.path} is not a Harness dial (${this.bytesSinceOpen} B, no frames) — releasing it`)
await this.link.close('not ours')
this.link = null
return
}
// Rule 2. The read never fails on a dead handle, so silence is the only symptom there is.
if (Date.now() - this.lastRx > SILENCE_MS) {
this.host.log('cable: silent, reopening the port')
// close() runs onClosed, which is where onDialGone fires — one path for "the dial is not there",
// whether the cable was pulled or the far end simply stopped answering.
await this.link.close('silence')
this.link = null
return
}
// The cadence is the contract: the dial reads a gap as absence, and the tick that watches for that
// gap runs far more often than the ping that prevents it.
if (Date.now() - this.lastPing >= PING_EVERY_MS) {
this.lastPing = Date.now()
await this.send({ t: 'ping' })
}
// "Say nothing when nothing changed" has to be paired with "say something when something did".
// Without this the list was sent ONCE per session and never corrected — and a daemon that has just
// restarted greets the dial before its registry has finished loading, so the one thing it ever said
// was "no agents". The dial removed both tiles and sat empty while the daemon knew about two.
if (this.greetedMac !== null) {
// Machines FIRST. The dial paints its Overview eyebrow from the machine list, so an agent list that
// lands first shows a nameless "Machine" for a frame.
if (Date.now() - this.machinesAt >= MACHINES_POLL_MS) {
this.machinesAt = Date.now()
await this.syncMachines()
}
// Swarms BEFORE agents: a swarm switch is a new swarm line and a new ring, and the line naming
// the tab should not lag the tiles that belong to it.
await this.syncSwarms()
await this.syncAgents()
}
}
private openAt = 0
private opening = false
/**
* The version an OTA is moving TO, or '' when none is in flight.
*
* Read by the device row the window draws — see onDialStatus. A dial that is
* taking an update is neither simply present nor absent, and the row says so.
*/
private offeringTo = ''
private lastPing = 0
/**
* Open the dial's port, at most ONE attempt at a time.
*
* ⚠️ THE `opening` FLAG IS THE FIX FOR A BUG THAT LOOKED LIKE BROKEN HARDWARE. `tick()` is fired by
* setInterval and never awaited, so runs overlap freely, and opening a port is not instant — it spawns
* `stty` and waits for it before it opens the descriptor. Under load that outlasts REOPEN_EVERY_MS, the
* next tick walks straight past the throttle below, and a second open begins while the first is still in
* flight. Only the last one is stored in `this.link`. The others are orphaned WITH THEIR READ LOOPS
* STILL RUNNING.
*
* That is not a descriptor quietly wasted. Every orphan reads the SAME tty and feeds the SAME decoder,
* so one byte stream arrives interleaved from several readers: frames are shredded, CRCs fail, and the
* dial's greetings disappear into the noise.
*
* Measured 2026-08-24 — five descriptors open on one port inside one process (`lsof` names them all),
* the daemon receiving 4 of the dial's 22 greetings in 45s, and a 20-byte ping taking 15 SECONDS to
* write. That last number is the dial's own silence deadline, so the session died and restarted forever
* and the screen sat on "0 agents" while this side logged, truthfully, that it had sent two.
*
* The dial was never at fault: sniffed directly with the daemon stopped, it emits one clean 71-byte
* greeting every 2.0s, indefinitely.
*/
private async tryOpen(): Promise<void> {
if (this.opening) return
if (this.foreignPort && Date.now() < this.foreignRetryAt) return
if (Date.now() - this.openAt < REOPEN_EVERY_MS) return
this.opening = true
this.openAt = Date.now()
let opened: CablePort | null
try {
// A late port must not be left running: the attempt we gave up on can still succeed afterwards, and
// an unowned open port with a live read loop is exactly the stranded reader this guard exists to
// prevent. So the loser of the race is closed rather than dropped.
const attempt = this.openPort(
(chunk) => this.onBytes(chunk),
(why) => this.onClosed(why),
)
let timer: ReturnType<typeof setTimeout> | undefined
const expiry = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`open timed out after ${OPEN_TIMEOUT_MS}ms`)), OPEN_TIMEOUT_MS)
})
try {
opened = await Promise.race([attempt, expiry])
} catch (err) {
void attempt.then((late) => late?.close('abandoned — open timed out')).catch(() => {})
throw err
} finally {
clearTimeout(timer)
}
} catch (err) {
// A port that another process holds is the ordinary case, not a fault: esptool, a serial monitor,
// or a second daemon. Say so and try again on the next tick.
this.host.log(`cable: cannot open the dial: ${(err as Error).message}`)
return
} finally {
this.opening = false
}
if (!opened) return // no dial plugged in — this daemon's resting state
// Nothing reaches this line holding a live port — tick() only calls in when the link is closed — but
// assigning over one would strand it exactly as above, and the cost of being sure is one branch.
if (this.link) await this.link.close('replaced')
// A port that earned the verdict once is presumed foreign until it proves otherwise, but the evidence
// is gathered fresh every time: a dial that was replaced behind the same path gets a clean hearing.
if (opened.path !== this.foreignPort) this.foreignPort = null
this.bytesSinceOpen = 0
this.framesSinceOpen = 0
this.link = opened
// Leftover bytes belong to a session that has ended; carrying them across would put a stale
// half-frame in front of the first real frame of the new one.
this.decoder.reset()
this.greetedMac = null
this.greetedFw = null
this.appFocusGeneration += 1
this.drivingAppFocus = false
this.expectedAppFocusEcho = ''
this.expectedAppFocusEchoUntil = 0
// A new port is a new dial until proven otherwise; tell it everything.
this.lastAgentsKey = ''
this.lastMachinesKey = ''
// INCLUDING which images it has been offered. `offered` holds bare version strings and named no dial,
// so without this it outlived the session its comment claims it belongs to and became per-DAEMON:
// offer 0.0.42 to one dial, unplug it, plug in a second still on 0.0.41, and the second is refused
// because the version — not the board — had already been offered. It sat there on the old image with
// nothing in the log to say why.
//
// Clearing it does NOT reopen the flash loop this guards against. That is `mayOffer`'s job, and it is
// keyed by MAC and outlives the port precisely so this one does not have to.
this.offered.clear()
this.lastRx = Date.now()
this.host.log(`cable: open on ${opened.path}`)
}
private onClosed(why: string): void {
this.host.log(`cable: closed (${why})`)
this.host.onDialGone?.()
this.host.onDialStatus?.({ attached: false })
this.link = null
this.greetedMac = null
this.greetedFw = null
this.appFocusGeneration += 1
this.expectedAppFocusEcho = ''
this.expectedAppFocusEchoUntil = 0
this.lastAgentsKey = ''
this.lastMachinesKey = ''
this.voice = null
// The dial keeps its running image; the half-written slot is erased again by the next accepted offer.
this.transfer?.finish('interrupted by the port closing')
this.transfer = null
}
// ── inbound ───────────────────────────────────────────────────────────────────────────────────────
private onBytes(chunk: Buffer): void {
this.lastRx = Date.now()
this.bytesSinceOpen += chunk.length
this.decoder.feed(chunk, (frame) => {
this.framesSinceOpen += 1
if (frame.type === CableType.Json) {
let msg: Message
try {
msg = JSON.parse(Buffer.from(frame.payload).toString('utf8')) as Message
} catch {
return // unreadable payloads are counted by the decoder, never fatal
}
void this.onMessage(msg)
return
}
if (frame.type === CableType.Log) {
// The dial's console. It shares its one USB port with this protocol, so these frames are the only
// way its log survives at all while the daemon holds the port.
const line = Buffer.from(frame.payload).toString('utf8')
void appendFile(this.logPath, `${new Date().toISOString()} ${line}\n`).catch(() => {})
return
}
if (frame.type === CableType.Pcm) {
this.onPcm(Buffer.from(frame.payload))
return
}
// An unknown type is a dial running ahead of this daemon. Visible, never fatal.
this.host.log(`cable: unknown frame type 0x${frame.type.toString(16)}`)
})
}
private async onMessage(msg: Message): Promise<void> {
const str = (key: string): string | undefined =>
typeof msg[key] === 'string' ? (msg[key] as string) : undefined
switch (msg.t) {
case 'hello': {
// ⚠️ A POSITIVE MATCH, AND ABSENCE MEANS NO. The framing magic already stops the sibling product's
// dial from ever getting this far, so reaching here with the wrong product means something changed
// that this code cannot see — a re-unified magic, a fork of this firmware, a third product. The
// safe reading of "I do not recognise you" is never "you are probably mine".
//
// Answered with nothing at all: a `welcome` is what starts a session, and there is no session to
// have with a dial that is not ours. The port is released on the next tick by the same rule that
// handles a dial we cannot decode.
const product = str('product')
if (product !== CABLE_PRODUCT) {
if (this.foreignPort !== this.link?.path) {
this.host.log(`cable: greeted by a '${product ?? 'nameless'}' dial, not a ${CABLE_PRODUCT} one — releasing the port`)
}
this.foreignPort = this.link?.path ?? null
this.foreignRetryAt = Date.now() + 60_000
await this.link?.close('another product')
this.link = null
return
}
const mac = str('mac') ?? ''
// Rule 1: every greeting is answered, but only an unfamiliar dial gets the full state.
await this.send({
t: 'welcome',
proto: CABLE_PROTO_VERSION,
app: this.host.appName(),
// The CABLED computer's identity — no longer "the one machine". It was the dial's own mac here
// until proto 2, which was simply wrong: a mac is not a machine id and nothing could select it.
machine: this.host.localMachine(),
// Stated up front so the ✓ is correct from the first frame, before any list arrives — which
// matters after a dial reboot that lands mid-session on a remote selection.
selected: this.host.selectedMachine(),
voiceLang: this.host.voiceLang(),
})
// Log a dial that is new OR that came back running something else. The version half of that test
// is not decoration: a dial reboots into its new image after an update and greets with the SAME
// mac, so keying the line on the mac alone suppresses the one line anybody wants after an OTA —
// "it came back, and on which version". Losing it left a successful 0.0.37 install unverifiable
// from the log on 2026-08-24.
const fw = str('fw') ?? '?'
if (mac !== this.greetedMac || fw !== this.greetedFw) {
const returning = mac === this.greetedMac
this.greetedMac = mac
this.greetedFw = fw
this.host.log(`cable: dial ${mac} ${returning ? 'back ' : ''}on fw ${fw} proto ${msg.proto}`)
this.host.onDialStatus?.({ attached: true, fw })
// BEFORE the state push, not after: the push reads the selected machine, and for a remote one
// that means an RPC over a lane this is what re-opens.
this.host.onDialAttached?.()
// Both halves of the test above mean the same thing to this line: a dial with nothing on its
// screen. A repeat greeting from the same dial on the same image is a keepalive and is skipped,
// which is the whole reason the branch exists.
await this.pushAgents()
}
// Offered on every greeting, but only ONCE per version per session: accepting makes the dial erase
// a flash slot before it answers, so a cadence of retries would spend erase cycles on the user's
// hardware every fifteen seconds, and nothing about the next greeting changes what went wrong.
await this.maybeOfferFirmware(str('fw') ?? '')
return
}
case 'pong':
return
case 'agents.list':
await this.pushAgents()
return
case 'machines.list':
await this.syncMachines(true)
return
case 'machine.select':
await this.selectMachine(str('machineId') ?? '')
return
case 'swarms.list':
await this.syncSwarms(true)
return
case 'swarm.select':
// Not answered here: the window switches, its desk changes, and the new `swarms` and ring
// pushes are the answer — the same shape as `machine.select`, minus the refusal, because the
// window never refuses to show a tab it has.
this.host.selectSwarm(str('swarmId') ?? '')
return
case 'models.list': {
// The one round trip in this protocol: the dial cannot draw a picker until the catalog is in hand,
// so it waits on this — briefly, and off its own UI task.
const agentId = str('agentId') ?? ''
let items: string[] = []
try {
items = await this.host.listModels(agentId)
} catch (err) {
this.host.log(`cable: models for ${agentId} failed (${(err as Error).message})`)
}
// Answered either way. An empty catalog closes the dial's picker cleanly; silence strands it on a
// spinner until its own timeout, which reads as a hang rather than "this engine has no choices".
await this.send({ t: 'models', agentId, items: items.map((id) => ({ id })) })
return
}
case 'focus':
if (str('agentId')) {
const agentId = str('agentId')!
const now = Date.now()
const expectationActive = this.expectedAppFocusEcho !== ''
&& now <= this.expectedAppFocusEchoUntil
if (expectationActive) {
// Until the commanded tile echoes, every other focus is from the carousel/list repaint that
// command caused. Do not even update the record: the daemon already commanded the newer truth.
if (agentId === this.expectedAppFocusEcho) {
this.expectedAppFocusEcho = ''
this.expectedAppFocusEchoUntil = 0
} else {
// SAID OUT LOUD. A dropped report is a dial and a window on different agents, and dropping
// it silently is how that state became impossible to explain from the log.
this.host.log(`cable: dropped dial focus ${agentId} — waiting for ${this.expectedAppFocusEcho}`)
}
return
}
if (now > this.expectedAppFocusEchoUntil) {
this.expectedAppFocusEcho = ''
this.expectedAppFocusEchoUntil = 0
}
// A machine switch re-paints the carousel and may report its OLD tile while the new list is in
// flight. That one report is not a hand and must not drive the window back.
//
// ONLY that one. This used to drop everything for as long as the switch took, and a remote
// machine's switch takes seconds — measured at ten. Every swipe made in that window vanished,
// so the dial walked on and the window stayed where it was, with the two screens naming
// different agents and nothing left to correct them. A dial that reports a move it did not make
// is fixed at the source now (carousel_goto in the firmware), which leaves only the stale repeat
// of the tile the dial was already on to guard against.
if (this.drivingAppFocus && now - this.drivingSince < APP_SWITCH_REPAINT_MS) {
this.host.log(`cable: dropped dial focus ${agentId} — repaint from the switch in flight`)
return
}
// Remember where the dial IS, not just that it said so: followApp() compares against this to
// avoid echoing the dial's own move back at it.
this.desiredFocus = agentId
this.host.focus(agentId)
}
return
case 'agent.open':
if (str('agentId')) this.host.openAgent(str('agentId')!)
return
case 'scroll': {
// Forwarded verbatim, including the reports carrying no travel: the two ends of a stroke are the
// whole point of the message. A `down` with nothing in it stops a fling still running, and an `up`
// with nothing in it is a finger that came to rest before it lifted and must not be thrown.
const phase = str('phase')
if (phase !== 'down' && phase !== 'move' && phase !== 'up') return
const dy = typeof msg.dy === 'number' ? msg.dy : 0
const v = typeof msg.v === 'number' ? msg.v : 0
this.host.scrolled(phase, dy, v)
return
}
case 'turn.send':
if (str('agentId') && str('text')) this.host.sendTurn(str('agentId')!, str('text')!)
return
case 'turn.stop':
if (str('agentId')) this.host.stopTurn(str('agentId')!)
return
case 'answer': {
// `answers` is the dial's own object, one entry per question, keyed by the keys WE asked with. It
// travels verbatim. Until proto 2 this case demanded `{id, optionId}` — a shape the dial has never
// sent — so every answer from the question screen was silently dropped on the floor.
const agentId = str('agentId')
const requestId = str('requestId')
const answers = msg.answers
if (agentId && requestId && answers && typeof answers === 'object' && !Array.isArray(answers)) {
const flat: Record<string, string> = {}
for (const [k, v] of Object.entries(answers as Record<string, unknown>)) {
if (typeof v === 'string') flat[k] = v
}
this.host.answer(agentId, requestId, flat)
}
return
}
case 'agent.update':
if (str('agentId')) this.host.updateAgent(str('agentId')!, str('model'), str('effort'))
return
case 'voice.begin':
this.voice = {
agentId: str('agentId'),
cmd: str('cmd'),
lang: str('lang') ?? this.host.voiceLang(),
// The DIAL's rate, never this side's guess. Describing 8 kHz audio as 16 kHz does not make the
// speech sound fast — the transcriber is handed a container that lies about itself and answers
// with nothing at all.
rate: typeof msg.sr === 'number' && msg.sr > 0 ? msg.sr : DEFAULT_VOICE_RATE,
chunks: [],
bytes: 0,
}
return
case 'voice.abort':
this.voice = null
return
case 'voice.end':
await this.finishVoice()
return
case 'voice.confirm':
if (str('routeId') && str('agentId')) this.host.focus(str('agentId')!)
return
case 'fw.accept':
// The dial has erased its slot and is expecting bytes. Nothing was sent before this.
// The window is told NOW rather than at the offer: an offer the dial refuses is nothing to
// show, and the minute that matters — do not unplug it — starts here.
this.host.onDialStatus?.({ attached: true, fw: this.greetedFw ?? undefined, updating: this.offeringTo })
await this.transfer?.pump()
return
case 'fw.progress':
// Not only a progress bar: this ack IS the credit that lets the next slices go.
if (typeof msg.written === 'number') await this.transfer?.onProgress(msg.written)
return
case 'fw.done':
// WHETHER AN UPDATE LANDS is not something the release process can see:
// a version is published, and after that the only evidence is the next
// `hello` from a dial that may never have taken it.
this.transfer?.finish('installed — the dial is rebooting')
this.transfer = null
this.offeringTo = ''
// Still attached from the window's side: the reboot re-greets within seconds and the fw
// field corrects itself then. Clearing `updating` is what matters.
this.host.onDialStatus?.({ attached: true, fw: this.greetedFw ?? undefined })
return
case 'fw.error':
// The outcome is `refused`, not the dial's message: that string is the
// firmware's own text and this stream carries short codes.
this.transfer?.finish(`refused: ${str('message') ?? 'no reason given'}`)
this.transfer = null
this.offeringTo = ''
this.host.onDialStatus?.({ attached: true, fw: this.greetedFw ?? undefined })
return
default:
this.host.log(`cable: unhandled message '${msg.t}'`)
}
}
/** Offer an update if there is one, the dial is not on it, and this session has not tried it already. */
/**
* May this version be written to this dial at all?
*
* DEFENCE IN DEPTH, AND IT IS THE LAYER THAT PROTECTS THE HARDWARE. Everything above decides whether a
* dial is ours; this decides how badly a wrong answer can hurt. An OTA writes three megabytes and
* reboots, so a pair of daemons that disagree about ownership do not merely argue — they take turns
* flashing the same board every fifteen seconds, which is about 700 MB an hour into a flash rated in
* erase cycles. The device is unusable throughout, and nothing about it looks like a loop from either
* side: each daemon sees a dial on an unexpected version and does the reasonable thing.
*
* `offered` alone does not cover it: that set is per SESSION, and every one of those flashes ends in a
* reboot that starts a new one. This memory is keyed by the dial and outlives the port.
*/
private mayOffer(mac: string, version: string): boolean {
const key = `${mac}:${version}`
if (this.written.has(key)) {
this.host.log(`cable: firmware ${version} already written to ${mac} — not offering it again`)
return false
}
const now = Date.now()
const recent = (this.writeLog.get(mac) ?? []).filter((at) => now - at < 60 * 60 * 1000)
if (recent.length >= FW_WRITES_PER_HOUR) {
this.host.log(`cable: ${mac} has taken ${recent.length} firmware writes this hour — holding off`)
return false
}
this.written.add(key)
this.writeLog.set(mac, [...recent, now])
return true
}
private async maybeOfferFirmware(runningVersion: string): Promise<void> {
if (!this.host.firmwareFor || this.transfer || !runningVersion) return
const candidate = await this.host.firmwareFor(runningVersion).catch(() => null)
// Keyed by DIAL as well as version. Holding bare version strings made this a statement about the
// image rather than about the board: offer 0.0.42 to one dial, swap in a second still on 0.0.41, and
// the second was refused because that version had been offered — to someone else. It then sat on the
// old image with nothing in the log to say why, which is how it was found.
if (!candidate) return
const mac = this.greetedMac ?? 'unknown'
const offerKey = `${mac}:${candidate.version}`
if (this.offered.has(offerKey)) return
if (!this.mayOffer(mac, candidate.version)) return
this.offered.add(offerKey)
this.host.log(`cable: offering firmware ${candidate.version} (${candidate.image.length} B)`)
this.offeringTo = candidate.version
this.transfer = new FirmwareTransfer(
candidate.image,
candidate.version,
async (slice) => {
if (!this.link?.isOpen) throw new Error('port closed mid-transfer')
await this.link.write(encodeCableFrame(CableType.Fw, slice))
},
(line) => this.host.log(line),
)
await this.send({ t: 'fw.offer', version: candidate.version, size: candidate.image.length, sha256: candidate.sha256 })
}
private onPcm(chunk: Buffer): void {
if (!this.voice) return // audio outside a turn is a dial that restarted mid-capture
this.voice.bytes += chunk.length
if (this.voice.bytes > VOICE_MAX_BYTES) {
this.host.log('cable: voice over the length cap, dropped')
this.voice = null
return
}
this.voice.chunks.push(chunk)
}
private async finishVoice(): Promise<void> {
const turn = this.voice
this.voice = null
if (!turn || turn.bytes === 0) {
await this.send({ t: 'voice.error', message: "Didn't catch that" })
return
}
const seconds = turn.bytes / (turn.rate * 2)
this.host.log(`cable: voice ${seconds.toFixed(1)}s (${Math.round(turn.bytes / 1024)} KB) → stt`)
let transcript: string
try {
transcript = (await this.host.transcribe(Buffer.concat(turn.chunks), turn.rate, turn.lang)).trim()
} catch (err) {
await this.send({ t: 'voice.error', message: (err as Error).message })
return
}
if (!transcript) {
await this.send({ t: 'voice.error', message: "Didn't catch that" })
return
}
// Named an agent: the dial was on a tile and there is nothing to decide.
let agentId = turn.agentId
let agentName = ''
const agents = await this.host.listAgents()
if (!agentId) {
// NOBODY NAMED, SO THE WINDOW DECIDES. It opens its palette with these words already in the field
// and runs the route a typed task would have run — the same fifteen candidates, the same trimmed
// recaps, the same threshold, and the same picker when the answer is not good enough to act on.
//
// The window DELIVERS what it picks, so there is nothing left to send here: a `sent` outcome is
// already on its way to an agent, and calling sendTurn on it would deliver the sentence twice.
const inWindow = this.host.routeInWindow
? await this.host.routeInWindow(transcript, turn.cmd)
: ({ t: 'unavailable' } as const)
if (inWindow.t === 'sent') {
this.host.log(`cable: the window routed the spoken task → ${inWindow.agentId.slice(0, 8)}`)
await this.send({
t: 'voice.transcript',
routeId: '',
text: transcript,
agentId: inWindow.agentId,
// Only a name the DIAL's own list knows: it draws this, and an agent the window reached on a
// machine the carousel has not been told about yet has no tile here to put a name on. The ring
// that follows the window's focus brings both along a moment later.
agentName: agents.find((a) => a.id === inWindow.agentId)?.name ?? '',
needsConfirm: false,
})
return
}
if (inWindow.t === 'cancelled') {
// A person closed the palette. Nothing was sent and nothing should be — but the dial is still
// showing the sending overlay, so it has to be told, or it sits there until its own watchdog.
await this.send({ t: 'voice.error', message: 'Cancelled in the window' })
return
}
if (inWindow.t === 'abandoned') {
// It took the words and went quiet. Routing here now would race a pick that may still be coming,
// and two turns from one sentence is worse than none — so say where the words went instead.
await this.send({ t: 'voice.error', message: 'Still waiting on the window' })
return
}
try {
const decision = await this.host.route(transcript, agents)
agentId = decision.agentId
this.host.log(`cable: routed → ${agentId} (${decision.reason})`)
} catch (err) {
await this.send({ t: 'voice.error', message: (err as Error).message })
return
}
}
agentName = agents.find((a) => a.id === agentId)?.name ?? ''
if (!agentId) {
await this.send({ t: 'voice.error', message: 'No agent to send that to' })
return
}
const text = turn.cmd ? `/${turn.cmd} ${transcript}` : transcript
this.host.sendTurn(agentId, text)
await this.send({ t: 'voice.transcript', routeId: '', text: transcript, agentId, agentName, needsConfirm: false })
}
// ── outbound ──────────────────────────────────────────────────────────────────────────────────────
private async send(msg: Message): Promise<boolean> {
if (!this.link?.isOpen) return false
try {
await this.link.write(encodeCableFrame(CableType.Json, Buffer.from(JSON.stringify(msg), 'utf8')))
return true
} catch (err) {
this.host.log(`cable: write failed (${(err as Error).message})`)
await this.link.close('write failed')
return false
}
}
/**
* Push the agent list, STREAMED — begin, one message per agent, end.
*
* A frame is capped at 8 KB and a hundred agents do not fit in it. One agent per message needs no chunk
* arithmetic on either side and bounds the message length by construction rather than by hoping the
* names stay short.
*/
/** What the dial has been told, as one comparable string. */
private static agentsKey(agents: CableAgent[]): string {
// `machine` is in the key, not just `machineId`: the dial DRAWS the name, so a machine being renamed
// has to reach the screen even though not one agent has changed.
return agents.map((a) => `${a.id}:${a.name}:${a.engine ?? ''}:${a.model ?? ''}:${a.effort ?? ''}:${a.machineId ?? ''}:${a.machine ?? ''}`).join('|')
}
/**
* Send the agent list IF it differs from what the dial was last told.
*
* Called on attach and on every tick. The tick is not belt-and-braces: the list can be wrong through no
* fault of the dial — a daemon restarting answers `hello` before its registry has finished loading, and
* the honest answer at that instant is "no agents". Something has to say the true one a second later.
*/
async syncAgents(force = false): Promise<void> {
return this.queued(() => this.syncAgentsNow(force))
}
private async syncAgentsNow(force: boolean): Promise<void> {
const agents = await this.host.listAgents()
const key = CableSession.agentsKey(agents)
if (!force && key === this.lastAgentsKey) return
this.lastAgentsKey = key
// Every push, and only pushes. The dial showing a different number from the daemon is a question this
// line answers in one look: either the daemon never said it, or it said it and the dial disagreed.
this.host.log(`cable: agents → ${agents.length}${force ? ' (attach)' : ''}`)
await this.send({ t: 'agents.begin' })
for (const a of agents) {
await this.send({
t: 'agent',
id: a.id,
name: a.name,
engine: a.engine ?? '',
model: a.model ?? '',
effort: a.effort ?? '',
// Where it lives. The id is what the dial sends back for every action, the name is what it draws
// on the switcher's second line, and the id is also how a machine row finds its first agent.
machineId: a.machineId ?? '',
machine: a.machine ?? '',
})
}
// How many of the agents just sent are on the CAROUSEL. They come first, so
// a count is enough, and a firmware that predates the field walks all of
// them exactly as it did before.
await this.send({ t: 'agents.end', ring: agents.filter((a) => !a.offRing).length })
// The list just changed shape under the dial, so say again which agent both screens are on.
//
// This is the ONLY thing the daemon re-asserts, and it is the current record rather than a second
// memory of it — see desiredFocus. A re-anchor after a push is silent by design (the dial reports
// nothing it did not do itself), so without this a dial that landed on the wrong tile would sit there
// with nobody to notice.
if (this.desiredFocus) {
this.expectedAppFocusEcho = this.desiredFocus
this.expectedAppFocusEchoUntil = Date.now() + APP_FOCUS_SETTLE_MS
await this.focusAgent(this.desiredFocus)
}
}
/**
* What each agent was last doing.
*
* A tile with a name and no recap has forgotten the work it belongs to, and that is what a dial shows
* every time it is replugged or the daemon restarts — the summaries were on disk the whole time, nobody
* had sent them.
*
* `restore: true` is the load-bearing part: history, not news. No beep, no notification, no busy state.
* Without it, plugging the cable in announces every turn that finished while it was unplugged.
*
* Sent on attach only. The list is re-sent whenever it changes; the history behind it does not, or every
* rename would replay a week of recaps.
*/
async pushRestores(): Promise<void> {
return this.queued(() => this.pushRestoresNow())
}
private async pushRestoresNow(): Promise<void> {
for (const a of await this.host.listAgents()) {
const past = await this.host.recentSummaries(a.id)
// Oldest first, so the newest ends up on top of the tile's stack.
for (const s of [...past].reverse()) {
if (!s.recap && !s.text) continue
await this.send({ t: 'summary', agentId: a.id, recap: s.recap, text: s.text, restore: true })
}
}
}
/** Attach: tell the dial everything, whether or not any of it looks unchanged from here. */
async pushAgents(): Promise<void> {
await this.syncMachines(true)
await this.syncSwarms(true)
await this.syncAgents(true)
await this.pushRestores()
}
// ── swarms ────────────────────────────────────────────────────────────────────────────────────────
private lastSwarmsKey = ''
/** Push the window's swarms IF they differ from what the dial was last told. Same rule as the wheel:
* the diff is what keeps the link idle while nothing changes. */
async syncSwarms(force = false): Promise<void> {
return this.queued(() => this.syncSwarmsNow(force))
}
private async syncSwarmsNow(force: boolean): Promise<void> {
const { selected, swarms } = this.host.listSwarms()
const key = `${selected}|${swarms.map((s) => `${s.id}:${s.name}:${s.agents}`).join('|')}`
if (!force && key === this.lastSwarmsKey) return
this.lastSwarmsKey = key
this.host.log(`cable: swarms → ${swarms.length}${selected ? ` (on ${selected})` : ''}${force ? ' [push]' : ''}`)
// ONE frame, not a begin/row/end stream: two dozen rows of an id, a name and a count fit in a
// kilobyte, and the dial replaces the whole list on arrival either way.
await this.send({ t: 'swarms', selected, items: swarms.map((s) => ({ id: s.id, name: s.name, agents: s.agents })) })
}
// ── machines ──────────────────────────────────────────────────────────────────────────────────────
/** What the dial has been told about the wheel, as one comparable string. Order counts: it is render
* order, so two lists that differ only by a swap are a real change. */
private static machinesKey(machines: CableMachine[], source: string, selected: string): string {
return `${source}|${selected}|${machines
.map((m) => `${m.id}:${m.name}:${m.state}:${m.local ? 1 : 0}`)
.join('|')}`
}
/**
* Push the machine wheel IF it differs from what the dial was last told.
*
* The diff is not an optimisation. `ui_local_machine_set` on the dial once rebuilt its wheel on every
* arriving greeting, on the USB reader task, and that measured out at 31 session restarts an hour with
* the agent list wiped each time — the screen sat on "0 agents" while the daemon believed it had said
* otherwise. The dial hashes the rows again on its side; this is the half that stops the bytes leaving.
*/
async syncMachines(force = false): Promise<void> {
return this.queued(() => this.syncMachinesNow(force))
}
private async syncMachinesNow(force: boolean): Promise<void> {
const { machines, source } = await this.host.listMachines()
const selected = this.host.selectedMachine()
const key = CableSession.machinesKey(machines, source, selected)
if (!force && key === this.lastMachinesKey) return
this.lastMachinesKey = key
this.host.log(`cable: machines → ${machines.length} (${source})${force ? ' [push]' : ''}`)
await this.send({ t: 'machines.begin' })
for (const m of machines) {
await this.send({ t: 'machine', id: m.id, name: m.name, state: m.state, local: m.local })
}
await this.send({ t: 'machines.end', selected, source })
}
/**
* Switch the dial to another machine.
*
* Always answered, including for the machine already selected: silence strands the dial on a spinner
* until its own deadline, which reads as a hang rather than as "done, nothing changed" — the same rule
* `models.list` follows.
*/
private async selectMachine(machineId: string): Promise<void> {
if (!machineId) return
if (machineId === this.host.selectedMachine()) {
await this.send({ t: 'machine.selected', machineId })
await this.syncAgents(true)
await this.pushRestores()
return
}
const result = await this.host.selectMachine(machineId)
if (!result.ok) {
this.host.log(`cable: machine.select ${machineId} refused (${result.code})`)
await this.send({ t: 'machine.error', machineId, code: result.code, message: result.message })
return
}
this.host.log(`cable: machine.select → ${machineId}`)
// REQUIRED, not hygiene: two machines whose agents happen to share names and engines produce an equal
// agentsKey, and the new machine's list would then never be sent at all.
this.lastAgentsKey = ''
await this.send({ t: 'machine.selected', machineId })
await this.syncAgents(true)
await this.pushRestores()
await this.syncMachines(true) // the ✓ moved, and the agent counts with it
}
/** One row changed — liveness, a rename, a count. Cheaper than re-streaming the wheel. */
async machineState(machine: CableMachine): Promise<void> {
await this.send({ t: 'machine.updated', id: machine.id, name: machine.name, state: machine.state, local: machine.local })
}
// Turn state is UNSOLICITED: the daemon reports every turn in every agent, including ones started at the
// keyboard. A dial that only saw answers to its own sends would sit idle through most of what the
// machine actually does.
/**
* `text` is the status line the tile draws — "Working…", the tool that is running, what it is waiting on.
* It travels because without it the dial gets a card with a state and nothing to render: the tile knows
* a turn is live and shows the user nothing that says so.
*/
async turnStarted(agentId: string, text = ''): Promise<void> {
await this.send({ t: 'turn.started', agentId, text })
}
async turnDone(agentId: string): Promise<void> {
await this.send({ t: 'turn.done', agentId })
}
/**
* A finished turn's recap.
*
* `quiet` means the window already has this agent on screen: draw the tile,
* skip the beep and the notification drawer. An extra field rather than a
* different frame, so firmware that predates it simply notifies as it always
* did instead of losing the recap.
*/
async summary(agentId: string, recap: string, text: string, quiet = false): Promise<void> {
await this.send(quiet ? { t: 'summary', agentId, recap, text, quiet: true } : { t: 'summary', agentId, recap, text })
}
async turnError(agentId: string, message: string): Promise<void> {
await this.send({ t: 'turn.error', agentId, message })
}
async question(agentId: string, id: string, questions: unknown): Promise<void> {
await this.send({ t: 'question', agentId, id, questions })
}
/**
* That question is no longer waiting — answered in the app, or in the pane by hand.
*
* Carries the id so a dial showing a DIFFERENT question cannot be closed by a stale message: the two
* screens are minutes apart on a slow question, and closing the wrong one loses an answer the user was
* halfway through giving.
*/
async questionClose(agentId: string, id: string): Promise<void> {
await this.send({ t: 'question.close', agentId, id })
}
async focusAgent(agentId: string): Promise<void> {
this.desiredFocus = agentId
await this.send({ t: 'focus', agentId })
}
/**
* The desktop window moved to an agent — bring the dial with it.
*
* ⚠️ THE `desiredFocus` GUARD IS WHAT KEEPS THIS FROM OSCILLATING. The two screens drive each other: the
* dial's own carousel reports `focus` up, the daemon hands that to the app, the app opens that agent's
* terminal, and the app opening a terminal is exactly what calls this. Answering it with another `focus`
* closes the ring. So: say nothing when the record already names that agent.
*
* The record is one field written by both sides (see desiredFocus). It used to be two, and the ring
* closed through the gap between them.
*
* The machine comes first when it differs. Sending `focus` for an agent on a machine the dial is not on
* would name an id its list has never heard of, and the tile it would have to move to does not exist
* yet — selectMachine is what streams that list.
*/
async followApp(machineId: string, agentId: string): Promise<void> {
if (!this.link?.isOpen || this.greetedMac === null) return
if (!agentId) return
const generation = ++this.appFocusGeneration
const task = this.appFocusTail.then(async () => {
// A newer desktop selection made this queued one obsolete before it started.
if (generation !== this.appFocusGeneration) return
this.drivingSince = Date.now()
this.drivingAppFocus = true
try {
if (machineId && machineId !== this.host.selectedMachine()) {
this.host.log(`cable: following the app to machine ${machineId}`)
await this.selectMachine(machineId)
}
// A newer selection can arrive while the remote machine RPC/list push is in flight. Never let
// this older transaction focus after it finishes.
// Already where the window is: nothing to command. The record is right either way — this is the
// window FOLLOWING the dial, and the dial's own report wrote it.
if (generation !== this.appFocusGeneration || agentId === this.desiredFocus) return
// Said before the frame goes out, not after: the frame itself succeeds either way.
const unknown = this.host.knows?.(agentId) === false
this.host.log(`cable: following the app to agent ${agentId}${unknown ? ' — NOT in this daemon\'s list, the dial has no tile for it' : ''}`)
this.expectedAppFocusEcho = agentId
this.expectedAppFocusEchoUntil = Date.now() + APP_FOCUS_SETTLE_MS
// THE LIST FIRST, THEN THE FOCUS — the fix for "clicking a rail agent that has no tile does not
// move the dial".
//
// A focus names a tile the dial has to CENTRE, and the dial can only centre what its ring walks.
// The click that brought us here is usually the very thing that changed the ring: opening an
// agent that had no tile puts it on the desk, and until that list is pushed the dial is still
// walking a ring where that agent sits off it — where the device drops the focus, by design, with
// no column to move to. Racing the two produced exactly the reported symptom: mostly it worked
// (the tick's push arrived first), sometimes it did not, and nothing in either log said which.
//
// The record is written BEFORE the push so the re-assert at the end of syncAgents names the agent
// we are moving to, not the one we are leaving — otherwise the dial visibly steps onto the old
// tile on its way. Cheap when nothing changed: syncAgents returns without sending a frame.
this.desiredFocus = agentId
await this.syncAgents()
if (generation !== this.appFocusGeneration) return
await this.focusAgent(agentId) // writes the record
} finally {
this.drivingAppFocus = false
}
})
// Keep the queue usable after one transport failure. The local websocket intentionally
// fire-and-forgets followApp, so the session log is the only place an unexpected rejection would
// otherwise be visible; consume it here rather than creating an unhandled rejection.
this.appFocusTail = task.catch((err) => {
this.host.log(`cable: could not follow app focus (${(err as Error).message})`)
})
await this.appFocusTail
}
async toast(text: string): Promise<void> {
await this.send({ t: 'toast', text })
}
}