Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
b6dddf934This file is pinned to the selected release. Download the original to open it in your design tool.
// `MachineFleet` for real: the REST machine list, plus one device socket to whichever machine the dial is
// currently on.
//
// The split is deliberate and load-bearing. The LIST costs an HTTP read and works signed-in-but-offline;
// the LANE costs a cloud socket and only exists while the user is actually looking at another machine. A
// dial parked on the local machine — which is where it sits most of the time — holds no socket at all.
import { isTerminalEngine } from '../engines/types.js'
import { FleetError, type FleetEvent, type FleetMachine, type MachineFleet } from '../cable/machineFleet.js'
import type { CableAgent } from '../cable/cableSession.js'
import type { RecentTurn } from '../cable/cableHost.js'
import type { MachineListCache } from './machineList.js'
import type { DeviceFrame, DeviceLink } from './deviceLink.js'
/** How long the socket lingers after the dial goes back to local, so flicking between two machines does
* not pay a full dial each way. Mirrors RemoteRelayPool's own linger for the same reason. */
const LINGER_MS = 60_000
/** Consecutive failed round trips before a machine's E2EE session is thrown away — see `rpc()`. */
const RESET_AFTER_FAILURES = 2
/** Split `runtime-v1:<sid>:<engine>:<model>@<effort>` into the two words the dial's chips show. */
function chipsFromProfile(profile: unknown): { model?: string; effort?: string } {
if (typeof profile !== 'string' || !profile.startsWith('runtime-v1:')) return {}
const tail = profile.split(':').slice(3).join(':')
if (!tail) return {}
const [model, effort] = tail.split('@')
return { model: model || undefined, effort: effort || undefined }
}
/** Map an `agents_list_result` row onto the cable's agent shape. */
function toCableAgent(raw: unknown): CableAgent {
const r = (raw ?? {}) as Record<string, unknown>
return {
id: typeof r.id === 'string' ? r.id : '',
name: typeof r.name === 'string' ? r.name : '',
engine: typeof r.engine === 'string' ? r.engine : '',
...chipsFromProfile(r.selectedModel),
}
}
export interface DeviceFleetOpts {
list: MachineListCache
link: DeviceLink
/** Whether this daemon holds a pinned E2EE key for a machine — see `harness link connect`. */
hasPeerLink: (machineId: string) => boolean
log: (line: string) => void
}
export class DeviceFleet implements MachineFleet {
private lingerTimer: NodeJS.Timeout | null = null
private readonly recapCache = new Map<string, RecentTurn[]>()
/** Filled by the same `agent_recent` round trip the recaps come from — see recentSummaries. */
private readonly askCache = new Map<string, string[]>()
/** `agent_recent` round trips in flight, so two callers asking at once share one — see recentSummaries. */
private readonly recapInFlight = new Map<string, Promise<RecentTurn[]>>()
private readonly listeners = new Set<(e: FleetEvent) => void>()
/**
* How the last round trip to each machine went.
*
* THE ONLY HONEST PROOF WE HAVE. A machine can look fine from every other angle and still swallow
* everything sent to it: the backend's list still calls it online, and the E2EE session still reports
* `ready` because it was established while the machine was alive — `establish()` returns instantly for
* a session that handshook an hour ago and has been deaf since. What actually knows is a request that
* came back, or did not.
*
* It costs no traffic of its own: the refresh loop already asks these machines for their agents every
* few minutes, so the answer is a by-product of work that was happening anyway.
*/
private readonly health = new Map<string, { ok: boolean; at: number }>()
/** Consecutive failed round trips per machine — the counter behind RESET_AFTER_FAILURES. */
private readonly misses = new Map<string, number>()
constructor(private readonly opts: DeviceFleetOpts) {
this.opts.link.onFrame((frame) => this.onFrame(frame))
}
async list(): Promise<{ machines: FleetMachine[]; source: 'backend' | 'local' | 'signed-out' }> {
const { machines, source } = this.opts.list.list()
return {
// The local row is added by the host, which knows facts about this computer that a snapshot cannot.
machines: machines.filter((m) => !m.local).map((m) => ({
...m,
// A machine with no pinned key is reachable but UNREADABLE: its agent list, its recaps and its
// turn cards are all encrypted to a key this daemon does not hold. Saying so in the row is what
// lets the wheel dim it and the guide behind it say which two commands fix it — far better than
// letting the user tap it and meet an empty carousel.
state: m.authMode === 'remote' && !this.opts.hasPeerLink(m.machineId) ? 'needs-link' as const : m.state,
})),
source,
}
}
/**
* The dial is plugged in: hold a socket for presence, attached to nothing.
*
* Cheap on purpose — see DeviceLink.online for why a held socket does not make any machine think a
* commander is watching it.
*/
async online(): Promise<void> {
await this.opts.link.online()
}
async select(machineId: string): Promise<void> {
const row = this.opts.list.find(machineId)
if (!row) throw new FleetError('UNKNOWN_MACHINE', 'That machine is not on your account')
// THIS computer is `authMode: 'remote'` like any other computer-backed machine, and it has no pinned
// key for itself — nor does it need one. Selecting it announces where the dial is; the agents behind
// it are read in-process, never over the relay, so there is nothing to encrypt and nothing to link.
if (!row.local && row.authMode === 'remote' && !this.opts.hasPeerLink(machineId)) {
// Checked HERE, locally and for free, so the guide appears instantly instead of after a connect
// that was always going to fail.
throw new FleetError('NEEDS_LINK', `Link ${row.name} to this computer first`)
}
if (this.lingerTimer) { clearTimeout(this.lingerTimer); this.lingerTimer = null }
try {
await this.opts.link.attach(machineId)
} catch (err) {
const message = (err as Error).message
throw new FleetError(this.codeFor(message), this.messageFor(message, row.name))
}
// ONLY this machine's recaps. Clearing the whole cache was right when a select meant "throw away the
// machine you were reading and pick up another one"; now every other machine's tiles are still on the
// dial's carousel, and blanking them would make a select look like N agents forgetting their work.
for (const key of [...this.recapCache.keys()]) if (key.startsWith(`${machineId}:`)) this.recapCache.delete(key)
for (const key of [...this.askCache.keys()]) if (key.startsWith(`${machineId}:`)) this.askCache.delete(key)
// NOTHING is read over the lane for this computer. Selecting it is an ANNOUNCEMENT — it tells the
// backend where the dial is — and its agents, turns and recaps all come from this process. Asking the
// cloud for them instead round-trips to ourselves and, because a computer-backed machine is
// `authMode: 'remote'`, is refused outright with E2EE_REQUIRED.
if (row.local) return
// Prefetch the recaps SERIALLY. Not caution — the contract: the agent list stays metadata-only and
// recaps are fetched one at a time precisely so no giant frame is ever built.
void this.prefetchRecaps(machineId)
}
/**
* `immediate` = the dial is gone, so the socket goes with it. Anything else is a NO-OP, and the reason
* is worth stating because this used to detach after a linger.
*
* Detaching sent `machine_deselect`, which drops the backend's ACTIVE machine pointer — and the backend
* refuses `agents_list`/`message` outright while there is no active machine, tagged for another machine
* or not (deviceWs's ensureActiveReady). So the linger would have quietly killed the carousel a minute
* after the user went back to the local machine. There is also nothing left for it to buy: with the
* `multi_machine` cap the backend holds a commander on every machine for as long as the socket lives,
* so detaching one of them frees nothing.
*/
release(immediate = false): void {
if (this.lingerTimer) { clearTimeout(this.lingerTimer); this.lingerTimer = null }
if (!immediate) return
this.opts.link.release()
this.recapCache.clear()
this.askCache.clear()
}
// ── EVERY method below NAMES ITS MACHINE ────────────────────────────────────────────────────────
// These used to `void machineId` and let the frame land on whatever machine was attached, because
// exactly one ever was. The dial now holds every machine's agents in one carousel, so the machine is
// no longer implied by the selection and has to travel with the frame: the backend routes a device
// frame by its outer `machineId` tag (deviceWs.handleFrame) and answers from THAT machine's node.
// Dropping the tag would not fail loudly — it would quietly ask the wrong computer.
async listAgents(machineId: string): Promise<CableAgent[]> {
const res = await this.rpc(machineId, 'agents_list', {})
const raw = Array.isArray(res.agents) ? res.agents : []
// This asks the far daemon as an ordinary client, so its answer is the app's list, terminals
// included — and the dial drives agents only, the same rule its own machine's list keeps
// (`deviceAgentRow`, `cableHost.localAgents`). A far daemon too old to know the engine sends
// none, which is the same thing.
return raw.map(toCableAgent).filter((a) => a.id && !isTerminalEngine(a.engine))
}
/**
* One round trip, and one record of how it went.
*
* Every RPC goes through here so `reachable()` reads a fact rather than a guess. It rethrows
* untouched — this is a witness, not a handler.
*/
private async rpc(machineId: string, type: string, payload: Record<string, unknown>): Promise<Record<string, unknown>> {
try {
const res = await this.opts.link.rpc(type, payload, machineId)
this.health.set(machineId, { ok: true, at: Date.now() })
this.misses.delete(machineId)
return res
} catch (err) {
this.health.set(machineId, { ok: false, at: Date.now() })
const missed = (this.misses.get(machineId) ?? 0) + 1
this.misses.set(machineId, missed)
// TWO IN A ROW AND THE SESSION GOES. A live machine drops the odd request; a machine that has gone
// away answers nothing, and the E2EE session it agreed to keeps every later call believing there is
// somebody at the other end — establish() returns instantly for a session in the map, so nothing
// ever rebuilds it. Measured: eight minutes of timeouts, twenty seconds apart, healed only by a
// daemon restart that happened for another reason.
//
// The counter is the pacing: two more failures cost two more timeouts, so a machine that is simply
// gone is retried on the order of half a minute rather than in a loop.
if (missed >= RESET_AFTER_FAILURES) {
this.misses.set(machineId, 0)
if (this.opts.link.resetSession(machineId)) {
this.opts.log(`device: ${machineId.slice(0, 8)} missed ${missed} in a row — dropped its E2EE session, next call rebuilds it`)
}
}
throw err
}
}
/**
* Whether the last thing we asked this machine came back.
*
* `null` for a machine nobody has asked yet — which is NOT the same as unreachable, and callers must
* not treat it as one: refusing to deliver to a machine we have simply never spoken to would turn a
* cold start into a failure.
*/
reachable(machineId: string): { ok: boolean; at: number } | null {
return this.health.get(machineId) ?? null
}
sendTurn(machineId: string, agentId: string, text: string): void {
// `resumeLatest` matches what the backend's own voice path dispatches, so a dial turn and a spoken
// turn land on the same session rather than one of them starting a new one.
this.tagged(machineId, { type: 'message', payload: { content: text, agentId, mode: 'auto', resumeLatest: true } })
}
stopTurn(machineId: string, agentId: string): void {
this.tagged(machineId, { type: 'cancel', payload: { agentId } })
}
answer(machineId: string, agentId: string, requestId: string, answers: Record<string, string>): void {
this.tagged(machineId, { type: 'question_response', payload: { agentId, requestId, answers } })
}
updateAgent(machineId: string, agentId: string, model?: string, effort?: string): void {
if (!model) return
// The far end owns what the profile means; this only reassembles the opaque string it round-trips.
const selectedModel = `runtime-v1:${agentId}:claude:${model}@${effort || 'auto'}`
void this.rpc(machineId, 'agent_update', { agentId, selectedModel })
.catch((err) => this.opts.log(`device: agent_update failed (${(err as Error).message})`))
}
async forkAgent(machineId: string, agentId: string): Promise<string> {
const res = await this.rpc(machineId, 'agent_fork', { agentId })
const agent = res.agent as { id?: unknown } | undefined
if (typeof res.error === 'string') throw new Error(typeof res.detail === 'string' ? res.detail : res.error)
if (typeof agent?.id !== 'string' || !agent.id) throw new Error('the machine answered without an agent')
return agent.id
}
async listModels(machineId: string, agentId: string): Promise<string[]> {
// `compact` is what keeps the catalog inside the dial's picker; the backend trims to ≤24 entries.
const res = await this.rpc(machineId, 'models_list', { agentId, compact: true })
const raw = Array.isArray(res.models) ? res.models : []
return raw.map((m) => (typeof m === 'string' ? m : String((m as Record<string, unknown>)?.id ?? ''))).filter(Boolean)
}
/** The person's own last questions to a remote agent, newest first. Cached with the recaps. */
async recentAsks(machineId: string, agentId: string): Promise<string[]> {
const key = `${machineId}:${agentId}`
const cached = this.askCache.get(key)
if (cached) return cached
await this.recentSummaries(machineId, agentId) // one round trip fills both
return this.askCache.get(key) ?? []
}
async recentSummaries(machineId: string, agentId: string): Promise<RecentTurn[]> {
const key = `${machineId}:${agentId}`
const cached = this.recapCache.get(key)
if (cached) return cached
// ONE round trip per agent, however many callers want it. Selecting a machine starts a prefetch AND a
// restore push, and both walk the same agents: without this the cold list is asked for twice, in two
// serial loops, and the second loop is the one the dial is waiting on.
const inflight = this.recapInFlight.get(key)
if (inflight) return inflight
const round = this.fetchRecent(machineId, agentId, key)
this.recapInFlight.set(key, round)
try {
return await round
} finally {
this.recapInFlight.delete(key)
}
}
private async fetchRecent(machineId: string, agentId: string, key: string): Promise<RecentTurn[]> {
try {
const res = await this.rpc(machineId, 'agent_recent', { agentId, n: 3 })
const events = Array.isArray(res.events) ? res.events : []
const turns = events.map((e) => {
const r = (e ?? {}) as Record<string, unknown>
// `ask` travels too. The wire has always carried it — commander.recent() puts it on every
// turn — and dropping it here left every agent on a REMOTE machine with no questions on
// record, so the router ranked them on their name alone while local agents were ranked on
// what the person had actually said to them.
return {
recap: typeof r.recap === 'string' ? r.recap : '',
text: typeof r.text === 'string' ? r.text : '',
ask: typeof r.ask === 'string' ? r.ask : '',
}
})
this.recapCache.set(key, turns)
this.askCache.set(
key,
(Array.isArray(res.asks) ? res.asks : []).filter((a): a is string => typeof a === 'string' && !!a.trim()),
)
return turns
} catch (err) {
this.opts.log(`device: agent_recent ${agentId} failed (${(err as Error).message})`)
return []
}
}
onEvent(cb: (event: FleetEvent) => void): () => void {
this.listeners.add(cb)
return () => this.listeners.delete(cb)
}
// ── internals ───────────────────────────────────────────────────────────────────────────────────
/** Send a frame addressed to one machine, sealed for it. The tag is what the backend routes on. */
private tagged(machineId: string, frame: DeviceFrame): void {
void this.opts.link.sendSealed({ ...frame, machineId })
}
private async prefetchRecaps(machineId: string): Promise<void> {
try {
const agents = await this.listAgents(machineId)
for (const a of agents) await this.recentSummaries(machineId, a.id)
} catch (err) {
this.opts.log(`device: recap prefetch failed (${(err as Error).message})`)
}
}
private onFrame(frame: DeviceFrame): void {
const selected = this.opts.link.selectedMachine
// WHICH MACHINE SENT IT. The hub stamps `machineId` on every frame it fans out to a commander
// client, and since the dial's carousel spans machines, that tag is the answer rather than a filter:
// a card from a machine the user is not looking at still belongs to a tile that is on screen.
//
// This used to DROP anything not wearing the selected machine's tag. That was right when one machine
// was attached at a time and is exactly wrong now — it would silence every tile but one, and the
// silence would look like agents that simply never finish a turn.
const from = typeof frame.machineId === 'string' && frame.machineId ? frame.machineId : selected
// OUR OWN cards, come back to us the long way. Selecting the local machine attaches a commander to
// it, so everything this daemon emits for the dial is fanned back down this socket — and the dial has
// already had it, in-process, milliseconds earlier. Dropping it here rather than at the consumer
// keeps the echo out of the event stream entirely.
if (frame.machineId && this.opts.list.find(frame.machineId)?.local) return
const payload = (frame.payload ?? {}) as Record<string, unknown>
if (frame.type === 'commander_event' && frame.agentId) {
const kind = payload.kind
if (kind !== 'processing' && kind !== 'done' && kind !== 'summary' && kind !== 'error') return
this.emit({
machineId: from,
kind,
agentId: frame.agentId,
text: typeof payload.text === 'string' ? payload.text : '',
recap: typeof payload.recap === 'string' ? payload.recap : '',
// A specialist on another Mac is as silent here as one on this one.
...(payload.subagent === true ? { subagent: true } : {}),
})
return
}
if (frame.type === 'commander_question' && frame.agentId) {
const requestId = payload.requestId
if (typeof requestId !== 'string' || !Array.isArray(payload.questions)) return
this.emit({ machineId: from, kind: 'question', agentId: frame.agentId, requestId, questions: payload.questions })
return
}
if (frame.type === 'node_status') {
this.emit({ machineId: from, kind: 'state', state: payload.online === true ? 'ready' : 'offline' })
return
}
// The live machine list. This is what the held socket buys: without it the wheel's dots are a REST
// snapshot up to a minute old, and a machine that just came up stays grey while the user looks at it.
if (frame.type === 'machines_status') {
const rows = Array.isArray(payload.statuses) ? payload.statuses : []
if (this.opts.list.applyLive(rows as Array<{ machineId?: unknown; online?: unknown }>)) {
this.emit({ machineId: from, kind: 'state', state: 'ready' })
}
}
}
private emit(event: FleetEvent): void {
for (const cb of this.listeners) cb(event)
}
/** Map the far end's words onto a code the dial can act on. Unknown reasons stay unreachable. */
private codeFor(message: string): string {
if (message.includes('NOT_YOUR_MACHINE')) return 'NOT_YOURS'
if (message.includes('MACHINE_ENV_MISMATCH')) return 'NOT_YOURS'
if (message.includes('PAYMENT') || message.includes('SUBSCRIPTION')) return 'BILLING'
if (message.includes('START_FAILED')) return 'UNREACHABLE'
return 'UNREACHABLE'
}
private messageFor(message: string, name: string): string {
if (message.includes('NOT_YOUR_MACHINE') || message.includes('MACHINE_ENV_MISMATCH')) {
return `${name} is not on this account`
}
if (message.includes('PAYMENT') || message.includes('SUBSCRIPTION')) return `${name} needs a subscription`
return `Can’t reach ${name}`
}
}