Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
556dde34bThis file is pinned to the selected release. Download the original to open it in your design tool.
import type { HarnessShareOwner } from './sharing/owner.js'
import { SHARE_REQUEST_TYPES } from './sharing/protocol.js'
import { AutonomousDeviceRelay } from './lib/autonomous-device/relay.js'
import type { AutonomousDeviceService, AutonomousDeviceFrame } from './lib/autonomous-device/service.js'
/**
* BackendSocket — the CLI's dial-out connection to the backend's `/api/adapter-ws`.
*
* The adapter occupies the NODE side of its agentId on the backend hub:
* - `up` { t:'up', frame } → normalized claude events + `<x>_result` RPC replies
* - `down` { t:'down', connId, frame } → web chat/control + data-plane RPC requests
*
* Mirrors the hosted runtime’s managerSocket: idempotent connect, exponential backoff (1s→30s),
* WS liveness (`lib/wsLiveness.ts`: ping every 20s, 60s deadline on silence) + a 15s app-level
* `{t:'ping'}` that refreshes the backend presence key, and a bounded FIFO queue for client-facing
* outbound frames.
*
* Auth: the SSO access token rides as the first WS subprotocol.
*/
import { WebSocket } from 'ws'
import { watchSocketLiveness, type LivenessWatch } from './lib/wsLiveness.js'
import { stat, readFile } from 'fs/promises'
import { isAbsolute, join } from 'path'
import { hostname, homedir } from 'os'
import { env } from './config/env.js'
import { AuthSessionManager, AuthSessionError } from './lib/authSession.js'
import { VERSION } from './version.js'
import { registry, projectDisplayName, type RegisteredSession } from './lib/registry.js'
import { ENGINES, PROCESS_ENGINES, isTerminalEngine, type AgentEngine, type ProcessEngine } from './engines/types.js'
import { listDir } from './lib/fsBrowse.js'
import { linkCodexProfile, listCodexProfiles } from './lib/codexProfiles.js'
import { gridCliPresence } from './lib/gridExec.js'
import { GridFleetRpc, GRID_FLEET_PROTOCOL, GRID_FLEET_MAX_TIMEOUT_MS, parseGridFleetRequest } from './lib/gridFleetRpc.js'
import { gridCapableEngines, parseGridLaunchOverride, type GridLaunchOverride } from './lib/gridLaunch.js'
import { listAllGridModels, resolveGridTarget } from './lib/gridModels.js'
import { deriveHarnessGridName } from './lib/gridDerive.js'
import { AGENT_NAME_RE, FirstPromptUnsupportedError, MAX_FIRST_PROMPT_CHARS, NamedAgentUnsupportedError, permissionModeApproves, permissionModeFlags, supportsFirstPrompt, supportsNamedAgent } from './lib/engineLaunch.js'
import { readAccountUsage, type AccountUsageReading } from './lib/accountUsage.js'
import { probeEngines } from './lib/engineProbe.js'
import { AgentCreationReceipts, AgentCreationReceiptError, creationFingerprint, validCreationId, type AgentCreationStatus } from './lib/agentCreationReceipt.js'
import { engineInstallRecipe } from './lib/engineInstall.js'
import { parseProjectFolder, prepareProjectFolder, ProjectFolderError } from './lib/projectFolder.js'
import { preTrustClaudeProject, preTrustCodexProject } from './lib/claudeTrust.js'
import { projectPreview } from './lib/projectPreview.js'
import { agentFrame, type AgentDshContext, type AgentFrame } from './lib/agentFrame.js'
import { installedDsh, listInstalledDsh } from './dsh/installed.js'
import { OrchestratorService } from './orchestrator/service.js'
import { OrchestratorError } from './orchestrator/model.js'
import { orchestratorRequest } from './orchestrator/wire.js'
import { shellQuote } from './orchestrator/prompts.js'
import type { SessionInputDelivery } from './lib/sessionInput.js'
import { engineLabel } from './lib/agentNames.js'
import { DSH_ID_RE } from './dsh/manifest.js'
import { refreshDshRegistry } from './dsh/catalog.js'
import type { DshInstallProgress } from './dsh/install.js'
import { dshInstallReply, dshInstallRequest, dshInstallStatus, dshListRows, dshRemoveId, dshRemoveReply } from './dsh/wire.js'
import { terminalHandoffRequest } from './lib/terminalHandoff.js'
import { routeVoiceTask } from './lib/voiceRouter.js'
import { tailFile } from './lib/sessions.js'
import { messagesToEvents, windowRawLines, subagentStatsFromRawLines, type SessionEvent } from './lib/normalize.js'
import { listFileTree, readProjectFile } from './lib/files.js'
import { MediaPreviewError, readMediaPreviewChunk } from './lib/mediaPreview.js'
import { ViewerForwarder } from './lib/viewerForwarder.js'
import { VIEWER_DOWN_TYPES } from './lib/viewerWire.js'
import { codexMessagesToEvents, windowCodexLines } from './engines/codex/normalizer.js'
import { codexSubagentResolverFor } from './engines/codex/subagent.js'
import { parseHostTheme, type HostTheme } from './lib/hostTheme.js'
import { cursorMessagesToEvents, windowCursorLines } from './engines/cursor/normalizer.js'
import { loadCursorReplayTaskLinks } from './engines/cursor/subagent.js'
import { opencodeMessagesToEvents, windowOpencodeMessages } from './engines/opencode/normalizer.js'
import { kiloMessagesToEvents, windowKiloMessages } from './engines/kilo/normalizer.js'
import { museMessagesToEvents } from './engines/muse/normalizer.js'
import { ampMessagesToEvents } from './engines/amp/normalizer.js'
import { grokMessagesToEvents } from './engines/grok/normalizer.js'
import { agyMessagesToEvents } from './engines/agy/normalizer.js'
import { copilotMessagesToEvents } from './engines/copilot/normalizer.js'
import { ampThreadToEvents, readAmpThread } from './engines/amp/threadExport.js'
import { piMessagesToEvents, windowPiLines } from './engines/pi/normalizer.js'
import { commandcodeMessagesToEvents, windowCommandCodeLines } from './engines/commandcode/normalizer.js'
import { hermesMessagesToEvents, windowHermesMessages } from './engines/hermes/normalizer.js'
import { devinMessagesToEvents, windowDevinMessages } from './engines/devin/normalizer.js'
import { readHermesMessages } from './engines/hermes/reader.js'
import { readDevinMessages } from './engines/devin/reader.js'
import { readOpencodeMessages } from './engines/opencode/reader.js'
import { readKiloMessages } from './engines/kilo/reader.js'
import { E2eeManager, type PairResult } from './lib/e2ee/manager.js'
import type { TerminalStreamManager } from './lib/terminalStreamManager.js'
import {
decodeTerminalHop,
encodeTerminalLocal,
encodeTerminalHop,
TerminalHopDirection,
type TerminalBinaryClear,
} from './lib/terminalBinary.js'
import { b64d, fingerprint, isWrapped } from './lib/e2ee/core.js'
import { encryptDownFrame, encryptRpcResult } from './lib/e2ee/applicationFrames.js'
import { DEVICE_RECENT_SAFE_FRAME_BYTES, fitRecentReplyPayloadForDevice } from './lib/deviceRecentTrim.js'
import { shouldReplayCommander } from './lib/commanderReplay.js'
import { RuntimeProfileControlError, type RuntimeProfileErrorCode } from './lib/runtimeProfileController.js'
import { parseRuntimeProfile, type RuntimeModelOption } from './lib/runtimeProfile.js'
import { sid, preview, logFrame } from './lib/log.js'
import {
TerminalP2pResponderPool,
TERMINAL_P2P_DOWN_TYPES,
TERMINAL_P2P_SIGNAL_TYPES,
type TerminalP2pData,
type TerminalP2pSignal,
} from './lib/terminalP2p.js'
// OpenCode has no per-session transcript file — its history is read from this SQLite store.
const OPENCODE_DB = join(env.OPENCODE_DATA_DIR, 'opencode.db')
// Kilo keeps history the same way opencode does, in its own store.
const KILO_DB = join(env.KILO_DATA_DIR, 'kilo.db')
const DEVIN_DB = join(env.DEVIN_HOME, 'sessions.db')
// Hermes history likewise comes from a SQLite store, not a per-session file.
const HERMES_DB = join(env.HERMES_HOME, 'state.db')
/** Answers the device `project_recent` RPC — set by cli.ts to the CommanderMirror's `recent`. */
export type RecentProvider = (sessionId: string, n: number) => Array<{ kind: string; text: string; recap?: string }>
const APP_PING_MS = 15_000
// Floor between two `app_presence` up-frames while a window is attached. Rides the 15s app-ping
// tick; the backend only needs to hear about it about once a minute (it floors its own Mongo write
// at five). `open` is never held back — it is the one that counts as a session in
// `user_daily_presence`.
const APP_PRESENCE_UP_MS = 60_000
// How long the opening handshake may take before the attempt is abandoned and retried. `ws` waits
// forever by default, and the heartbeat below only starts on 'open' — so a TCP connection that came
// up while the network was flapping but never got its upgrade answered sat in CONNECTING for hours,
// `this.ws` set, every later connect() returning early, and the daemon reporting "cloud
// reconnecting…" until someone restarted it.
const HANDSHAKE_TIMEOUT_MS = 15_000
/** How long `resolveGridName` waits for a grid reconcile still in flight before answering with
* whatever is resolved. Well under the app's 12s `grid_models_list` timeout, leaving that RPC room
* for its own `grid` spawns; a reconcile slower than this lands by the next open. */
const GRID_ATTACH_WAIT_MS = 6_000
const BASE_DELAY_MS = 1_000
const MAX_DELAY_MS = 30_000
const QUEUE_MAX = 2_000
const DEVICE_AGENT_LIST_LIMIT = 100
const DEVICE_AGENT_NAME_MAX_CODEPOINTS = 15
const DEVICE_AGENT_NAME_MAX_BYTES = 39 // device project_t.name[40], including trailing NUL on-device.
const DEVICE_ELLIPSIS = '…'
export type Frame = Record<string, unknown>
type OutboundEnvelope = Record<string, unknown>
export interface LocalClientSink {
sendFrame: (frame: Frame) => boolean
sendBinary: (frame: Uint8Array) => boolean
}
/** A loopback desktop connection (localWsServer.ts), as opposed to a cloud/relay one. */
export function isLocalClientId(connId: string): boolean {
return connId.startsWith('local:')
}
/**
* Where a down-frame came from, carried with it to `dispatchDown`.
*
* `relay` is the backend link and ONLY the backend link; `local` is a process on this machine
* talking to the daemon's local socket; `p2p` is a paired device over its own channel. The
* distinction is a trust boundary, not bookkeeping: a handful of frames are the backend's alone to
* send, and before `local` existed they were accepted from anything that could open the local port.
*/
export type DownTransport = 'relay' | 'local' | 'p2p'
/**
* Down-frames only the BACKEND may send, refused from every other transport.
*
* Each one hands the daemon an instruction no client is entitled to give:
* - `machine_meta` names the account's private grid — the inference endpoint every agent on this
* computer is then pointed at. Forged, it redirects the account's work to a grid of the
* sender's choosing. A leftover test script did exactly this by accident once.
* - `machine_revoked` clears the stored SSO session and exits the daemon. Forged, it is a
* one-frame forced sign-out and denial of service.
*
* Neither is sent by any client in this repository — only by `backend/src/lib/adapterWs.ts` and
* `backend/src/services/MachineService.ts` — so there is nothing to stay compatible with. The
* backend blocks its OWN `__`-prefixed control frames from web clients for the same reason; these
* two escaped that rule because they are not `__`-prefixed.
*/
const BACKEND_ONLY_DOWN_TYPES = new Set(['machine_meta', 'machine_revoked'])
interface QueueItem {
id: number
data: string
msg: OutboundEnvelope
attempts: number
}
interface DownEnvelope {
t: 'down'
connId?: string
frame?: Frame
}
function byteLen(s: string): number {
return Buffer.byteLength(s, 'utf8')
}
function clipDeviceAgentName(input: string): string {
const chars = [...input]
if (chars.length <= DEVICE_AGENT_NAME_MAX_CODEPOINTS && byteLen(input) <= DEVICE_AGENT_NAME_MAX_BYTES) return input
const ellipsisBytes = byteLen(DEVICE_ELLIPSIS)
let out = ''
for (const ch of chars.slice(0, DEVICE_AGENT_NAME_MAX_CODEPOINTS)) {
if (byteLen(out) + byteLen(ch) + ellipsisBytes > DEVICE_AGENT_NAME_MAX_BYTES) break
out += ch
}
return `${out}${DEVICE_ELLIPSIS}`
}
/**
* Amp history comes from AMP's store, not from ours.
*
* The plugin's JSONL is a record of what the plugin saw; a thread that ran before the integration existed
* — or in a pane still holding an older plugin — is simply not in it, and no local file can rebuild those
* turns. `amp threads export` returns the thread complete, including the tools Amp runs server-side.
*
* The local file stays as the FALLBACK, because an export is a network call and a pane with no history at
* all is worse than a partial one. Which source answered is logged either way: silently serving the lesser
* record is exactly how the missing tool cards went unnoticed for a day.
*/
async function ampHistory(sessionId: string, lines: string[]): Promise<SessionEvent[]> {
const messages = await readAmpThread(sessionId)
if (messages) {
console.log(`[history] ${sessionId.slice(0, 12)} amp · ${messages.length} message(s) from amp's own store`)
return ampThreadToEvents(messages)
}
console.warn(`[history] ${sessionId.slice(0, 12)} amp · export unavailable — falling back to the local transcript`)
return ampMessagesToEvents(lines)
}
/** Grok has no transcript windower yet. Keep BOTH `session_get` shapes on the same real-record replay:
* web always sends a limit, while legacy callers omit it. Returning the whole small transcript for a
* page is honest (`hasMore:false`) and cannot fall through to Claude's incompatible line cursor. */
export function grokHistoryPage(lines: string[], paginated: boolean):
{ events: SessionEvent[]; hasMore?: false; oldestCursor?: null } {
const events = grokMessagesToEvents(lines)
return paginated ? { events, hasMore: false, oldestCursor: null } : { events }
}
/** agy has no transcript windower either; same both-shapes replay as grok, for the same reason. */
export function agyHistoryPage(lines: string[], paginated: boolean):
{ events: SessionEvent[]; hasMore?: false; oldestCursor?: null } {
const events = agyMessagesToEvents(lines)
return paginated ? { events, hasMore: false, oldestCursor: null } : { events }
}
/** Copilot has no transcript windower either; same both-shapes replay as grok and agy. */
export function copilotHistoryPage(lines: string[], paginated: boolean):
{ events: SessionEvent[]; hasMore?: false; oldestCursor?: null } {
const events = copilotMessagesToEvents(lines)
return paginated ? { events, hasMore: false, oldestCursor: null } : { events }
}
export function deviceAgentListItem(
raw: unknown,
): { id: unknown; name?: string; engine?: ProcessEngine; selectedModel?: string | null } {
const o = (raw ?? {}) as Record<string, unknown>
const item: { id: unknown; name?: string; engine?: ProcessEngine; selectedModel?: string | null } = { id: o.id }
if (typeof o.name === 'string') item.name = clipDeviceAgentName(o.name)
// The dial only ever meets process engines — a terminal never reaches it (see `deviceAgentRow`),
// and the union here says so rather than repeating fourteen string literals.
if (typeof o.engine === 'string' && (PROCESS_ENGINES as readonly string[]).includes(o.engine)) item.engine = o.engine as ProcessEngine
// Runtime model/effort profile (opaque runtime-v1:...) — lets the device render + change model/effort.
if (typeof o.selectedModel === 'string' || o.selectedModel === null) item.selectedModel = o.selectedModel
return item
}
/**
* Whether an agent row belongs on a device at all. The dial drives agents — a terminal with nobody
* running in it has no turn to watch, no question to answer and no model to switch, so it is not
* listed there; the same row becomes listable the moment an engine is started inside it and its
* `engine` flips (registry `adoptEngine`).
*/
export function deviceAgentRow(raw: unknown): boolean {
const o = (raw ?? {}) as Record<string, unknown>
return !isTerminalEngine(typeof o.engine === 'string' ? o.engine : undefined)
}
/**
* How many models the DEVICE picker may receive. Matches its own PICK_MAX (ui_screens.c) so the wheel
* never renders more rows than it was built for; the web picker is unbounded and still gets everything.
*/
const DEVICE_PICKER_MAX_MODELS = 24
export function compactRuntimePickerModels(
models: RuntimeModelOption[],
sessionId: string | undefined,
pickerMode: unknown,
selectedModel: unknown,
): Array<{ id: string }> {
const compact = models.map(({ id }) => ({ id }))
if ((pickerMode !== 'model' && pickerMode !== 'effort') || !sessionId) return compact
const profiles = models.flatMap((item) => {
const profile = parseRuntimeProfile(item.id)
return profile?.sessionId === sessionId ? [{ item, profile }] : []
})
const selected = parseRuntimeProfile(selectedModel)
const current = selected?.sessionId === sessionId ? selected : null
if (pickerMode === 'effort') {
if (!current) return []
const seen = new Set<string>()
return profiles.flatMap(({ item, profile }) => {
if (profile.model !== current.model || profile.effort === 'auto' || seen.has(profile.effort)) return []
seen.add(profile.effort)
return [{ id: item.id }]
})
}
const byModel = new Map<string, typeof profiles>()
for (const entry of profiles) {
const group = byModel.get(entry.profile.model) ?? []
group.push(entry)
byModel.set(entry.profile.model, group)
}
const rows = [...byModel.values()].map((group) => {
const target = group.find(({ profile }) => current && profile.effort === current.effort)
?? group.find(({ profile }) => profile.effort === 'auto')
?? group[0]
return { id: target.item.id, model: target.profile.model }
})
// Top N only. The picker is a scroll wheel on a 1.9" round screen, and a 49-row one was enough to stall
// the device's the device UI task into a task-watchdog reset; devin alone publishes 72
// models. The catalog arrives in the engine's own order — its curated/most-used first — so "top" is that
// order, with the model the agent is RUNNING pinned in front so the list can never hide it.
const ordered = current
? [...rows].sort((a, b) => Number(b.model === current.model) - Number(a.model === current.model))
: rows
return ordered.slice(0, DEVICE_PICKER_MAX_MODELS).map(({ id }) => ({ id }))
}
/** Fill in missing sub-agent aggregates on tool_end events by reading the sub-agent's own transcript
* (`<session>/subagents/agent-<id>.jsonl`). Async/background launchers only record
* `{status:'async_launched', agentId}` in the main transcript — without this join the delegation
* card shows "0 tools · worked for 0s" forever. Best-effort per agent; missing files are skipped. */
async function enrichSubagentStats(events: SessionEvent[], transcriptPath: string): Promise<void> {
const subagentsDir = join(transcriptPath.replace(/\.jsonl$/, ''), 'subagents')
for (const e of events) {
if (e.type !== 'tool_end') continue
const sub = e.payload.subagent
if (!sub?.agentId || typeof sub.totalToolUseCount === 'number') continue
try {
const txt = await readFile(join(subagentsDir, `agent-${sub.agentId}.jsonl`), 'utf8')
const stats = subagentStatsFromRawLines(txt.split('\n'))
sub.totalToolUseCount = stats.totalToolUseCount
if (sub.totalDurationMs === undefined) sub.totalDurationMs = stats.totalDurationMs
if (sub.totalTokens === undefined) sub.totalTokens = stats.totalTokens
} catch { /* subagent transcript absent (still spawning / pruned) — leave as-is */ }
}
}
export class BackendSocket {
private readonly gridFleet = new GridFleetRpc()
private ws: WebSocket | null = null
private connecting = false
/** A 401 on the upgrade is being answered with a token refresh; that refresh owns the next connect. */
private retryingAuth = false
private readonly auth: AuthSessionManager
/** Constructor-without-auth is retained for isolated unit tests only. */
private readonly testToken?: string
private url: string
private attempts = 0
private closed = false
private queue: QueueItem[] = []
private draining = false
private nextQueueId = 1
private droppedSinceLog = 0
private heartbeat: LivenessWatch | null = null
private appPing: NodeJS.Timeout | null = null
private lastAppPresenceUpAt = 0
// A window attached while there was no link to tell (cold start: the app dials this daemon before
// the daemon has dialed the backend; or a daemon restart under an open window). The session is
// real and must be counted once, so it is owed to the next link — not turned into a `ping`.
private appOpenOwed = false
private readonly downChains = new Map<string, Promise<void>>()
private readonly localClients = new Map<string, LocalClientSink>()
private terminalStreams: TerminalStreamManager | null = null
private readonly terminalP2p: TerminalP2pResponderPool
private readonly p2pPendingOpens = new Map<string, Set<string>>()
private readonly p2pStreams = new Map<string, Set<string>>()
private onStatus: (connected: boolean) => void
/** Cross-instance commander (device) client count, from backend `__clients` frames. */
private commanderCount = 0
/** Subset of commanderCount whose device is ACTIVELY rendering this machine (multi-attach). null = the
* backend doesn't send the signal (old build) → fall back to hasCommander so streaming isn't gated off. */
private commanderActive: number | null = null
private replayedCommanderGeneration: number | undefined
private replayCommanderOnNextSnapshot = true
/** Called when a commander attach is observed — cli.ts replays live state. */
onCommanderJoin: (() => void) | null = null
/** Called only when commander presence crosses zero; drives the disposable recap-worker grace. */
onCommanderPresenceChanged: ((connected: boolean) => void) | null = null
/** Called when the web cancels a turn (C-c) — cli.ts stops that session's turn heartbeat. */
onCancel: ((sessionId: string) => void) | null = null
/** Called when the web deletes an agent (`agent_delete`) — cli.ts signals only the validated engine
* process and forgets the session. Keeps recap + agent name. */
onDeleteAgent: ((sessionId: string) => void) | null = null
/** Called on `agent_create` — cli.ts spawns a fresh tmux session running the requested engine in the
* requested folder and returns its process-agent. Session metadata may bind later through hooks. */
onCreateAgent: ((input: {
engine: AgentEngine
cwd: string
bypassPermission: boolean
/** A grid to point this agent at instead of the engine's own login; null when none was chosen. */
grid: GridLaunchOverride | null
/** A Codex CODEX_HOME folder to launch this agent against instead of `~/.codex`; codex only. */
codexHome: string | null
/** The domain-specific harness to create this agent as (installed here, base engine = `engine`). */
dsh: string | null
/** The message the session opens with, already submitted (`FIRST_PROMPT_ARGS` in engineLaunch.ts);
* null when the pane opens on an empty input. Validated here — length, and that the engine has a
* contract for it — so cli.ts never sees one it cannot hand over. Never logged. */
prompt: string | null
/** The name the pane opens under, instead of the next `agent-N`; null to number it. */
name: string | null
/** The engine's named agent the pane opens AS (`NAMED_AGENT_ARGS` in engineLaunch.ts; opencode
* `--agent <name>`); null for a general session. Validated here — shape, and that the engine has
* a contract for it. Unlike `prompt`, kept on the row so a relaunch opens as it again. */
agent: string | null
/** A mode from `PERMISSION_MODES` for this engine; null when the client sent only
* `bypassPermission`, which then decides. Validated here (`INVALID_PERMISSION_MODE`). */
permissionMode: string | null
}) =>
Promise<{ ok: true; session: RegisteredSession } | { ok: false; error: string; detail?: string }>) | null = null
/** Called on `dsh_install` — cli.ts clones/sets up/doctors the harness and reports each phase. */
onDshInstall: ((input: { id?: string; url?: string; ref?: string }, progress: (p: DshInstallProgress) => void) =>
Promise<{ ok: true; id: string } | { ok: false; error: string; detail: string }>) | null = null
/** An explicit update follows the installed source and retains the previous package on failure. */
onDshUpdate: ((id: string, progress: (p: DshInstallProgress) => void) =>
Promise<{ ok: true; id: string } | { ok: false; error: string; detail: string }>) | null = null
/** Called on `dsh_remove` — cli.ts uninstalls the harness from this machine. */
onDshRemove: ((id: string) => { ok: true } | { ok: false; error: string; detail: string }) | null = null
/** Called on `remote_terminal_handoff` — cli.ts names the agent whose tile is that tmux pane, or null. */
onTerminalHandoff: ((tmuxPane: string) => string | null) | null = null
/** What the daemon knows about an agent's DSH companions (viewer URL, verdict); null when nothing. */
harnessSharing: HarnessShareOwner | null = null
dshFrameProvider: ((session: RegisteredSession) => AgentDshContext | null) | null = null
viewerTargetProvider: ((agentId: string) => string | null) | null = null
readonly viewerForwarder = new ViewerForwarder({
target: (agentId) => this.viewerTargetProvider?.(agentId) ?? null,
send: (connId, type, payload) => {
if (this.localClients.has(connId)) { this.sendTo(connId, { type, payload }); return true }
if (!this.isConnected()) return false
const frame = this.e2ee.wrapTarget(connId, type, payload)
if (!frame) return false
this.sendTo(connId, frame)
return true
},
})
private readonly agentCreations = new AgentCreationReceipts(join(env.ADAPTER_DATA_DIR, 'agent-creations'))
/** Injectable for queue-isolation tests; production uses the machine-local probe. */
engineProbeProvider: typeof probeEngines = probeEngines
/** Entrypoint override for isolated integration fixtures; never a wire option. */
orchestratorCommand: string | null = null
onCancelOrchestratorMessage: ((deliveryId: string) => boolean) | null = null
orchestratorDelivery(event: SessionInputDelivery): void {
this.orchestratorService?.delivery(event)
}
private orchestratorService: OrchestratorService | null = null
private orchestration(): OrchestratorService {
return this.orchestratorService ??= new OrchestratorService({
stateDir: join(env.ADAPTER_DATA_DIR, 'orchestrator'),
workspaceDir: join(homedir(), 'harnesses', 'orchestrated'),
command: this.orchestratorCommand ?? `${[process.execPath, ...process.execArgv, process.argv[1]].map(shellQuote).join(' ')} orchestrator --port ${env.PORT} --machine ${shellQuote(this.machineId)}`,
catalog: () => listInstalledDsh().filter(d => d.manifest.kind !== 'viewer' && !!d.manifest.engine && supportsFirstPrompt(d.manifest.engine)).map(d => ({
id: d.id, name: d.manifest.name, description: d.manifest.description ?? '', engine: d.manifest.engine!, viewer: !!d.manifest.viewer,
})),
supportsEngine: engine => ENGINES.includes(engine as AgentEngine) && supportsFirstPrompt(engine as AgentEngine),
create: async input => {
if (!this.onCreateAgent) throw new OrchestratorError('UNSUPPORTED', 'This daemon cannot create agents.')
const available = await this.engineProbeProvider([input.engine])
if (!available.some(e => e.engine === input.engine && e.installed)) throw new OrchestratorError('ENGINE_NOT_INSTALLED', `${input.engine} must be installed before starting this specialist.`)
const result = await this.onCreateAgent({ ...input, grid: null, codexHome: null, agent: null, permissionMode: null })
if (!result.ok) throw new OrchestratorError(result.error, result.detail ?? result.error)
return { agentId: result.session.agentId }
},
send: (id, text, deliveryId) => {
if (!this.onMessage || !registry.resolve(id)) throw new OrchestratorError('AGENT_UNAVAILABLE', 'The agent is not available to receive a message.')
this.onMessage(id, text, deliveryId)
},
cancelDelivery: id => this.onCancelOrchestratorMessage?.(id) ?? false,
cancel: id => this.onCancel?.(id),
agent: id => {
const agent = registry.resolve(id)
if (!agent) return null
const context = this.dshFrameProvider?.(agent)
return { viewerUrl: context?.viewerUrl, viewerName: context?.viewerName, error: agent.launch?.state === 'failed' ? agent.launch.detail ?? agent.launch.error : null }
},
changed: (id, revision) => this.sendLocal({ type: 'orchestrator_changed', payload: { id, revision } }),
})
}
/**
* Called on `agent_retarget` — cli.ts re-execs an EXISTING agent's pane against a different grid,
* or, when `grid` is null, back onto its own login.
*
* Separate from `onCreateAgent` because it is a different promise: the pane, its id and its
* scrollback survive, and only the process is replaced. A running process's environment cannot be
* edited, so there is no gentler way to move an agent that is already up.
*
* Separate from `onRestartAgent` because that one puts the agent back exactly as it was; this one
* puts it back somewhere else. They share the swap underneath and differ in what they hand it.
*/
onRetargetAgent: ((input: { agentId: string; grid: GridLaunchOverride | null }) =>
Promise<{ ok: true } | { ok: false; error: string; detail?: string }>) | null = null
/** Called on `agent_restart` — cli.ts stops the agent's live engine process and relaunches it in the
* SAME tmux pane, keeping the SAME agentId and (best-effort) resuming the same engine session.
* `resumed` tells the caller whether the relaunch actually resumed the prior conversation or had to
* fall back to a fresh one under the same agent/pane. */
onRestartAgent: ((agentId: string) =>
Promise<
{ ok: true; session: RegisteredSession; resumed: boolean }
| { ok: false; error: string; detail?: string }
>) | null = null
/** Called on `agent_fork` — cli.ts opens a NEW agent that starts with `agentId`'s whole history
* (lib/forkAgent.ts) and returns its process-agent, exactly as `agent_create` does. `level` says
* what the new agent actually got: the engine's own fork, or a handoff message. */
onForkAgent: ((input: { agentId: string; name: string | null; prompt: string | null }) =>
Promise<
{ ok: true; session: RegisteredSession; level: 'native' | 'handoff' }
| { ok: false; error: string; detail?: string }
>) | null = null
/** Called when the web/device sends chat input to an agent terminal. */
onMessage: ((sessionId: string, content: string, deliveryId?: string) => void) | null = null
/** Best-effort terminal-native title sync after a user renames an agent. */
onAgentRename: ((session: RegisteredSession, name: string) => void) | null = null
/** Called when a device answers an AskUserQuestion (`question_response`) — cli.ts drives the CLI's own
* terminal dialog (option digit / free text), since a remote machine has no
* programmatic answer channel the way the hosted runtime’s brain does. */
onQuestionAnswer: ((payload: { requestId?: string; sessionId?: string; agentId?: string; answers?: Record<string, string> }) => void) | null = null
/** Called when this machine was deleted/revoked (a `machine_revoked` down-frame, or a 401/403 on the
* upgrade) — CLI clears the saved SSO session and shuts down instead of retrying forever. */
onRevoked: (() => void) | null = null
/** Called with the machine's display name (`machine_meta` down-frame: seeded on connect, pushed on a
* web rename; null = unnamed) — cli mirrors it to a local file for `harness status`. */
onMachineMeta: ((name: string | null) => void) | null = null
/** Called when this machine is already connected from ANOTHER computer (HTTP 409 on the upgrade) — the
* SSO session is valid, so CLI keeps it and stops without a retry loop. */
onBusy: (() => void) | null = null
/** Answers the device `project_recent` RPC (cli.ts wires this to CommanderMirror.recent). */
recentProvider: RecentProvider | null = null
/** The person's own last questions for an agent, newest first. See the `agent_recent` case. */
recentAsksProvider: ((agentId: string, n: number) => string[]) | null = null
/** Runtime Model/Effort integration, wired by cli.ts for registered tmux sessions. */
runtimeModelsProvider: ((sessionId?: string) => Promise<RuntimeModelOption[]>) | null = null
/** Answers `usage_read` — this machine's own agent-account usage (lib/accountUsage.ts). A field
* rather than a direct call so a spec answers it without a real home, Keychain or network. */
accountUsageReader: () => Promise<AccountUsageReading[]> = readAccountUsage
/** The grid listing currently out, shared by every `grid_models_list` for the same own grid
* that lands meanwhile. */
private gridModelsInFlight: { gridName: string | null; grids: ReturnType<typeof listAllGridModels> } | null = null
/** Receives `theme_set` — the desktop's pane colours, to become this machine's tmux
* `window-style` (lib/hostTheme.ts). Wired by cli.ts; null answers with UNSUPPORTED. */
hostThemeSink: ((theme: HostTheme) => void) | null = null
runtimeProfileProvider: ((session: RegisteredSession) => string | null) | null = null
onRuntimeProfileUpdate: ((sessionId: string, selectedModel: string) => Promise<void>) | null = null
/** Web↔adapter E2EE: group-encrypts user events, runs the CPace pairing, holds per-conn sessions. */
readonly e2ee: E2eeManager
/** Backend-resolved machine id, persisted by the SSO login preflight. */
readonly machineId: string
/** The ONE place commander presence changes. Both callers — the `__clients` snapshot and `onGone` (our
* own backend link died) — mean the same thing when the count reaches zero: nobody is watching. They
* used to differ, and `onGone` forgot to drop the device's E2EE session, so `deviceE2eeConnected()`
* stayed true and the local dashboard kept a green "device connected" dot for a device long gone. */
private setCommanderCount(commander: number, active: number | null): void {
const hadCommander = this.commanderCount > 0
this.commanderCount = commander
this.commanderActive = active
if (hadCommander !== (commander > 0)) this.onCommanderPresenceChanged?.(commander > 0)
if (commander <= 0) {
if (this.directDeviceSinks.size) this.e2ee.dropSessionsByRole('device', id => this.directDeviceSinks.has(id))
else this.e2ee.dropSessionsByRole('device')
}
}
/** True while at least one device (commander) client is connected — gates the LLM recap. */
hasCommander(): boolean {
return this.commanderCount > 0
}
/** True while at least one connected device is ACTIVELY rendering this machine — gates the full turn-card
* STREAM (processing/tool/todos). The recap still runs on hasCommander(), so a BACKGROUND machine gets its
* turn-done `summary` card (badge) without the live stream. Falls back to hasCommander() against a
* backend that doesn't emit the signal (commanderActive === null). */
hasActiveCommander(): boolean {
return this.commanderActive == null ? this.hasCommander() : this.commanderActive > 0
}
/** True while a desktop window on THIS computer is attached over loopback.
*
* Separate from `hasCommander()` on purpose: a device and a window are different audiences that
* happen to want some of the same work done. The question watcher is the first thing to need it —
* polling a pane for a dialog is pointless with nobody rendering it, but "nobody" used to mean
* "no device", which left the window unable to learn that an agent was blocked. */
hasLocalClient(): boolean {
return this.localClients.size > 0
}
/** True after a paired device has completed the E2EE hello/welcome session. */
deviceE2eeConnected(): boolean {
return this.e2ee.deviceConnected()
}
private readonly directDeviceSinks = new Map<string, (frame: Record<string, unknown>) => void>()
private readonly directDevicePins = new Map<string, string>()
onDirectDeviceRevoked?: (fingerprint: string) => void
attachDirectDevice(connId: string, send: (frame: Record<string, unknown>) => void): void { this.directDeviceSinks.set(connId, send) }
detachDirectDevice(connId: string): void { this.directDeviceSinks.delete(connId); this.directDevicePins.delete(connId); this.e2ee.dropSession(connId); this.autonomousDeviceRelay?.drop(connId); this.onCommanderPresenceChanged?.(this.hasCommander()) }
pairedDirectFingerprint(connId: string): string | null { const pub = this.directDevicePins.get(connId); return pub ? fingerprint(b64d(pub)) : null }
async receiveDirectDevice(connId: string, frame: Record<string, unknown>, pairingAllowed: boolean): Promise<void> {
if (!this.directDeviceSinks.has(connId)) return
const type = frame.type
if (type === 'machine_selected') return
if (type === 'autonomous_device_request') { await this.autonomousDeviceRelay?.handle(connId, frame); return }
const controls = pairingAllowed ? ['e2e_pair_intent', 'e2e_pair_cancel', 'e2e_pake', 'e2e_hello', 'e2e_status'] : ['e2e_hello', 'e2e_status']
if ((type === 'e2e_pake' || type === 'e2e_pair_cancel') && this.e2ee.pendingConnection() !== connId) return
if (typeof type === 'string' && controls.includes(type)) this.e2ee.handleFrame(connId, frame)
}
private autonomousDeviceRelay?: AutonomousDeviceRelay
setAutonomousDeviceService(service: AutonomousDeviceService): void {
this.autonomousDeviceRelay = new AutonomousDeviceRelay(
this.e2ee,
(connId, frame) => this.sendTo(connId, frame),
service,
this.machineId,
() => this.onCommanderJoin?.(),
identity => this.e2ee.revoke(fingerprint(b64d(identity))),
)
}
directAutonomousDeviceSessions(): number { return this.autonomousDeviceRelay?.count(id => this.directDeviceSinks.has(id)) ?? 0 }
autonomousDeviceConnected(): boolean { return this.autonomousDeviceRelay?.connected() ?? false }
emitAutonomousDeviceEvent(frame: AutonomousDeviceFrame): void { this.autonomousDeviceRelay?.emit(frame) }
/** Live backend link state (local dashboard + E2EE gating). */
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
/** A browser waiting to pair (local dashboard), or null. */
pendingPair(): ReturnType<E2eeManager['pendingPair']> {
return this.e2ee.pendingPair()
}
/** Record the local dashboard port so it's surfaced to the web (in e2e_status) for approve-via-web. */
setDashboardPort(port: number): void {
this.e2ee.dashboardPort = port
}
constructor(machineId: string, auth?: AuthSessionManager, onStatus: (connected: boolean) => void = () => {}, computerId = '', autonomousEnv = 'prod') {
this.auth = auth ?? new AuthSessionManager(env.BACKEND_WS_URL.replace(/\/$/, '').replace(/^wss:/, 'https:').replace(/^ws:/, 'http:'))
this.testToken = auth ? undefined : machineId
// `?label=<hostname>` lets the backend record which machine connected (shown on the machine card);
// `?computer=<stable id>` enforces one-computer-per-computer (a 2nd computer is rejected with HTTP 409);
// `?v=<VERSION>` is our own version, which the backend stores on the machine at every connect.
const base = `${env.BACKEND_WS_URL.replace(/\/$/, '')}/api/adapter-ws?label=${encodeURIComponent(hostname())}&v=${encodeURIComponent(VERSION)}&autonomousEnv=${encodeURIComponent(autonomousEnv)}`
// `?machine=<id>` is the machine this daemon still believes it is. The backend uses it to tell a
// REVOKED daemon — one whose machine was deleted while it was offline — apart from a first-time
// pairing, and answers 403 instead of quietly minting a replacement machine. Guarded on shape
// because in test mode the first constructor argument carries a token, not a machine id.
const claim = /^[a-f0-9]{32}$/.test(machineId) ? `&machine=${encodeURIComponent(machineId)}` : ''
this.url = (computerId ? `${base}&computer=${encodeURIComponent(computerId)}` : base) + claim
this.onStatus = onStatus
this.machineId = machineId
this.e2ee = new E2eeManager({
machineId: this.machineId,
sendTo: (connId, frame) => this.sendTo(connId, frame),
sendUser: (frame) => this.sendUser(frame),
isConnected: () => this.isConnected(),
isConnectionAvailable: connId => this.directDeviceSinks.has(connId) || this.isConnected(),
onIdentityPaired: (connId, pub) => { if (this.directDeviceSinks.has(connId)) this.directDevicePins.set(connId, pub) },
onIdentityRevoked: identity => { this.autonomousDeviceRelay?.revoke(identity); this.onDirectDeviceRevoked?.(fingerprint(b64d(identity))) },
onSessionDropped: (connId) => this.viewerForwarder.closeConnection(connId),
})
this.terminalP2p = new TerminalP2pResponderPool({
sendSignal: (connId, type, payload) => this.sendP2pSignal(connId, type, payload),
onData: (connId, data) => this.handleP2pData(connId, data),
onUnavailable: (connId, reason) => this.demoteP2pConnection(connId, reason),
})
}
setTerminalStreamManager(manager: TerminalStreamManager): void {
this.terminalStreams = manager
}
/** Run CPace pairing for a code entered via `harness pair <code>` (delegated to the manager). */
pair(code: string): Promise<PairResult> {
return this.e2ee.onPair(code)
}
e2eeFingerprint(): string {
return this.e2ee.fingerprint()
}
createSetupToken(): ReturnType<E2eeManager['createSetupToken']> {
return this.e2ee.createSetupToken()
}
/** `harness pairings` — list paired clients. */
listPairs(): ReturnType<E2eeManager['listPaired']> {
return this.e2ee.listPaired()
}
/** `harness unpair <id>` — unpair one client (signals it to re-pair if online). */
revoke(id: string): ReturnType<E2eeManager['revoke']> {
return this.e2ee.revoke(id)
}
/** `harness unpair --all` — unpair every client. */
revokeAll(): ReturnType<E2eeManager['revokeAll']> {
return this.e2ee.revokeAll()
}
/** `harness remote-password set` — stretch + persist a new persistent remote password. */
setRemotePassword(password: string): ReturnType<E2eeManager['setRemotePassword']> {
return this.e2ee.setRemotePassword(password)
}
/** `harness remote-password clear` — remove the persistent remote password. */
clearRemotePassword(): void {
this.e2ee.clearRemotePassword()
}
/** `harness remote-password status` — whether one is set, and its fingerprint. */
remotePasswordStatus(): ReturnType<E2eeManager['remotePasswordStatus']> {
return this.e2ee.remotePasswordStatus()
}
/** The account's private harness grid name, as the backend last reported it. Null until the first
* `machine_meta` lands, or when this account has none yet. */
private harnessGridName: string | null = null
/** Injected so the derivation (a `grid` spawn) is a seam in tests; see `lib/gridDerive.ts`. */
deriveGridName: () => Promise<string | null> = deriveHarnessGridName
/** The daemon-start grid reconcile (`lib/gridAttach.ts`), while it is running — so the first
* `grid_models_list` / retarget after an update waits for the sign-in it may still be arranging
* rather than answering "no grid". Set by `cli.ts`; returns null when nothing is in flight. */
gridReadyProbe: (() => Promise<unknown> | null) | null = null
/** Set the account's private grid name from the reconcile that just confirmed it, so the RPCs
* answer with it at once rather than waiting for the next `machine_meta` (`lib/gridAttach.ts`). */
setHarnessGridName(name: string | null): void { this.harnessGridName = name }
/** Which grid this machine's agents can be pointed at — for `harness status` and the models RPC. */
gridName(): string | null { return this.harnessGridName }
/**
* The account's private grid: the backend's word when it gave one, else what this machine can
* work out for itself (`lib/gridDerive.ts`). A backend that predates `machine_meta.gridName`
* left every picker empty while `grid models` listed the model fine; the derivation is the
* skill's own rule, so the daemon and the agent it opens agree on which grid is "yours".
*
* Waits, once and briefly, for a grid reconcile still in flight — the machine that just updated is
* signing in to grid in the background, and a picker opened in that window would otherwise read
* "no grid" for the one moment the answer is about to arrive. Bounded so a slow reconcile (a fresh
* sign-in and a grid create) never holds the RPC past the app's own timeout; whatever is resolved
* by then is answered, and the next open — after the reconcile has landed — is correct regardless.
*/
private async resolveGridName(): Promise<string | null> {
const inFlight = this.gridReadyProbe?.()
if (inFlight) {
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
inFlight.catch(() => {}),
new Promise<void>((resolve) => { timer = setTimeout(resolve, GRID_ATTACH_WAIT_MS) }),
])
if (timer) clearTimeout(timer)
}
return this.harnessGridName ?? await this.deriveGridName()
}
connect(): void {
if (this.closed || this.ws || this.connecting) return
this.connecting = true
void this.connectWithSession()
}
private async connectWithSession(): Promise<void> {
let token: string
try {
token = this.testToken ?? await this.auth.accessToken()
} catch (err) {
this.connecting = false
this.onStatus(false)
const delay = err instanceof AuthSessionError && err.code === 'INVALID_REFRESH' ? MAX_DELAY_MS : BASE_DELAY_MS
if (!this.closed) setTimeout(() => this.connect(), delay)
return
}
if (this.closed) { this.connecting = false; return }
// On timeout `ws` emits 'error' ("Opening handshake has timed out") then 'close', which lands in
// onGone below and re-enters the ordinary backoff — the same path a refused connection takes.
const ws = new WebSocket(this.url, [token], { handshakeTimeout: HANDSHAKE_TIMEOUT_MS })
this.ws = ws
this.connecting = false
ws.on('open', () => {
this.attempts = 0
console.log(`[backend] connected → ${this.url}`)
this.onStatus(true)
this.drainQueue()
this.heartbeat = watchSocketLiveness(ws, {
onIdle: (idleMs) => console.log(`[backend] no traffic for ${Math.round(idleMs / 1000)}s — terminating the link`),
})
// App-level ping refreshes the backend's presence key (TTL 30s). The window's presence rides
// the same tick — a fresh socket knows nothing about the window, so its first tick goes through.
this.lastAppPresenceUpAt = 0
if (this.appOpenOwed && this.localClients.size > 0) this.sendAppPresence('open')
this.appPing = setInterval(() => {
this.sendBestEffort({ t: 'ping' })
if (this.localClients.size > 0) this.sendAppPresence('ping')
}, APP_PING_MS)
})
ws.on('message', (raw, isBinary) => {
if (isBinary) {
const hop = decodeTerminalHop(new Uint8Array(raw as Buffer))
if (hop?.direction === TerminalHopDirection.down) this.enqueueTerminalBinary(hop.connId, hop.clientFrame)
return
}
let env_: DownEnvelope
try { env_ = JSON.parse(raw.toString()) as DownEnvelope } catch { return }
if (env_.t === 'down' && env_.frame) {
// A malformed/hostile down-frame (bad __e2e envelope, bad ephemeral key) can throw in the
// pre-`try` part of dispatchDown; without this .catch that becomes an unhandledRejection and the
// daemon exits. Contain it: log, drop the frame, keep the socket alive.
this.enqueueDown(env_.frame, env_.connId ?? '')
}
})
const onGone = (why: string): void => {
if (this.ws !== ws) return
this.ws = null
if (this.heartbeat) { this.heartbeat.stop(); this.heartbeat = null }
if (this.appPing) { clearInterval(this.appPing); this.appPing = null }
this.draining = false
// While the backend link is down we can neither observe device presence nor deliver a card, so
// default the recap gate to OFF (safe value) instead of holding a stale count — otherwise a turn
// completing during the gap burns a `claude -p` recap that goes nowhere. attachAdapter always
// re-pushes the true count via recomputeAndSendClients on reconnect (and 0→N re-fires the replay).
this.harnessSharing?.closeAll()
this.setCommanderCount(0, null) // active count is unknown until the next __clients snapshot
this.viewerForwarder.closeAll()
void this.terminalStreams?.closeConnectionsWhere(
(connId) => !isLocalClientId(connId),
'backend disconnected',
false,
)
void this.terminalP2p.stop()
this.p2pPendingOpens.clear()
this.p2pStreams.clear()
this.replayCommanderOnNextSnapshot = true
this.onStatus(false)
if (this.closed) return
// A 401 refresh owns the next connect (see the error handler below): no competing backoff timer,
// or two sockets would race for the one machine claim.
if (this.retryingAuth) { console.log(`[backend] disconnected (${why}) — refreshing the token before reconnecting`); return }
const delay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.min(this.attempts++, 5))
console.log(`[backend] disconnected (${why}) — retrying in ${Math.round(delay / 1000)}s (attempt ${this.attempts})`)
setTimeout(() => this.connect(), delay)
}
ws.on('close', (code) => onGone(`close ${code}`))
ws.on('error', (err) => {
const e = err as Error & { code?: string }
const msg = e.message || e.code || String(err)
console.error('[backend] socket error:', msg)
// 401 on the upgrade = the access token was refused. Refresh it and come back; the socket is
// torn down the ordinary way below (`ws.close()` → onGone: timers, status, streams), which is
// what the previous shape skipped — it nulled `this.ws` first, so onGone returned at its first
// line, status kept saying connected, and a refresh that failed for ANY reason (a network blip
// included) wiped the SSO session. Only a refresh token the backend itself rejects means the
// session is over; everything else is a transient and re-enters the backoff.
if (/Unexpected server response: 401\b/.test(msg) && !this.retryingAuth) {
this.retryingAuth = true
void this.auth.accessToken({ force: true, failedToken: token })
.then(() => {
this.retryingAuth = false
// onGone has normally run by now (the close lands long before a network round trip
// returns); if this socket is somehow still ours, let go of it before dialing again.
if (this.ws === ws) { this.ws = null; try { ws.terminate() } catch { /* ignore */ } }
this.connect()
})
.catch((error: unknown) => {
this.retryingAuth = false
// No session to refresh, or a refresh token the backend rejects: the session is over.
if (error instanceof AuthSessionError && (error.code === 'INVALID_REFRESH' || error.code === 'MISSING')) {
this.closed = true
this.onRevoked?.()
return
}
if (this.closed) return
if (this.ws === ws) { this.ws = null; try { ws.terminate() } catch { /* ignore */ } }
const delay = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** Math.min(this.attempts++, 5))
console.log(`[backend] token refresh failed (${error instanceof Error ? error.message : String(error)}) — retrying in ${Math.round(delay / 1000)}s (attempt ${this.attempts})`)
setTimeout(() => this.connect(), delay)
})
} else if (/Unexpected server response: 40[13]\b/.test(msg)) {
this.closed = true
this.onRevoked?.()
}
// 409 = another computer already holds this machine. Keep the SSO session; stop
// retrying (the 40[13] regex above deliberately excludes 409, so without this it would loop).
else if (/Unexpected server response: 409\b/.test(msg)) {
this.closed = true
this.onBusy?.()
}
try { ws.close() } catch { /* ignore */ }
})
}
async stop(): Promise<void> {
this.closed = true
this.orchestratorService?.stop()
this.viewerForwarder.closeAll()
if (this.heartbeat) this.heartbeat.stop()
if (this.appPing) clearInterval(this.appPing)
await this.terminalStreams?.stop()
await this.terminalP2p.stop()
try { this.ws?.close() } catch { /* ignore */ }
this.ws = null
await this.harnessSharing?.stop()
}
/** Send an up-frame (event or RPC reply) to the WEB audience. Queued while disconnected.
* User-content events are group-encrypted (E2EE) here; system frames pass through as plaintext. */
send(frame: Frame): void {
// Only an already-open orchestration service observes events; ordinary sessions
// do not create project state or incur disk work. Project payloads stay local.
this.orchestratorService?.ingest(frame)
if (env.LOG_FRAMES) logFrame('→', 'web', frame)
for (const [connId, sink] of this.localClients) {
if (!sink.sendFrame(frame)) void this.unregisterLocalClient(connId)
}
this.enqueue({ t: 'up', frame: this.e2ee.wrapUp(frame) })
}
/** Send an up-frame to the LOOPBACK clients only — never to the cloud.
*
* For things that describe what is happening at THIS desk rather than what the machine is doing: the
* dial is a physical object on one table, and a finger moving on its glass is meaningful to the window
* in front of it and to nothing else. `send()` fans out to the web audience as well, which would scroll
* a window on a computer the user is not sitting at. */
sendLocal(frame: Frame): void {
if (env.LOG_FRAMES) logFrame('→', 'local', frame)
for (const [connId, sink] of this.localClients) {
if (!sink.sendFrame(frame)) void this.unregisterLocalClient(connId)
}
}
/** Ask one local desktop to select focus, without opening panes in every window. */
sendFirstLocal(frame: Frame): boolean {
for (const [connId, sink] of this.localClients) {
if (sink.sendFrame(frame)) return true
void this.unregisterLocalClient(connId)
}
return false
}
/** Send an up-frame to exactly ONE web connection (E2EE pairing/welcome + targeted RPC replies). */
sendObserver(connId: string, type: string, payload: Record<string, unknown>): boolean {
return this.sendBestEffort({ t: 'up', targetConnId: connId, webEligible: false, commanderEligible: false, frame: { type, payload } })
}
sendTo(connId: string, frame: Frame): void {
const direct = this.directDeviceSinks.get(connId)
if (direct) { direct(frame); return }
const local = this.localClients.get(connId)
if (local) {
if (!local.sendFrame(frame)) void this.unregisterLocalClient(connId)
return
}
this.enqueue({ t: 'up', targetConnId: connId, frame })
}
/** Pairwise terminal output is never queued across reconnect: the stream/lease is closed on link loss. */
sendTerminalTo(connId: string, type: string, payload: Record<string, unknown>): boolean {
const local = this.localClients.get(connId)
if (local) return local.sendFrame({ type, payload })
const frame = this.e2ee.wrapTarget(connId, type, payload)
if (!frame) return false
if (this.routeTerminalOutputToP2p(connId, type, payload)) {
if (this.terminalP2p.send(connId, JSON.stringify(frame))) return true
this.demoteP2pConnection(connId, 'send_failed')
}
return this.sendBestEffort({
t: 'up',
targetConnId: connId,
webEligible: true,
commanderEligible: false,
frame,
})
}
/** Pairwise-encrypted binary terminal output/keyframe. The hop prefix exposes
* only connId and direction to the opaque backend relay. */
sendTerminalBinaryTo(connId: string, clear: TerminalBinaryClear): boolean {
const local = this.localClients.get(connId)
if (local) {
const frame = encodeTerminalLocal(clear)
return frame ? local.sendBinary(frame) : false
}
const clientFrame = this.e2ee.wrapTerminalBinary(connId, clear)
if (!clientFrame) return false
if (this.p2pStreams.get(connId)?.has(clear.streamId)) {
if (this.terminalP2p.send(connId, Buffer.from(clientFrame))) return true
this.demoteP2pConnection(connId, 'send_failed')
}
const packet = encodeTerminalHop(TerminalHopDirection.up, connId, clientFrame)
if (!packet || !this.ws || this.ws.readyState !== WebSocket.OPEN) return false
try { this.ws.send(packet); return true } catch { return false }
}
/** Send a user-level notification to every logged-in browser that owns this machine. */
sendUser(frame: Frame): void {
this.enqueue({ t: 'up', userEligible: true, webEligible: false, frame })
}
/** Send a DEVICE-audience frame (commanderEligible, not web). User/data frames are group-encrypted
* (E2EE) here so the backend relays only ciphertext; system/presence frames pass through. */
/**
* A tap on everything bound for a device, taken BEFORE E2EE wrapping.
*
* The dial on the USB cable is a second device audience, and it wants exactly what this one gets — the
* same `commander_event` cards, in the same order, with the same recaps. Teeing here rather than adding
* a parallel emit at each of the two dozen call sites is what keeps the two surfaces from drifting: a
* new event kind reaches the cable the day it reaches the socket, without anyone remembering to add it.
*
* Plaintext on purpose: E2EE exists because the backend relays those frames. The cable relays nothing —
* it is a wire the user physically owns, running to a process on their own computer.
*/
onOutboundCommander?: (frame: Frame) => void
sendCommander(frame: Frame): void {
this.onOutboundCommander?.(frame)
if (env.LOG_FRAMES) logFrame('→', 'device', frame)
this.enqueue({ t: 'up', webEligible: false, commanderEligible: true, frame: this.e2ee.wrapCommander(frame) })
}
/**
* The desktop window is open on this computer: tell the backend, which turns it into the person's
* `user_daily_presence` row. The window itself says nothing — its loopback socket IS the fact, so
* this daemon reports it: `open` the moment a window registers (registerLocalClient), `ping` on the
* app-ping tick while any window is attached, at most once per APP_PRESENCE_UP_MS. Best-effort and
* plaintext on purpose: it is bookkeeping about the person, not data, and a daemon that is signed
* out (no backend dial) or between reconnects simply drops it rather than queueing a stale "was
* open" behind real frames. Returns whether a frame went up.
*/
sendAppPresence(kind: 'open' | 'ping'): boolean {
const now = Date.now()
if (kind === 'ping' && now - this.lastAppPresenceUpAt < APP_PRESENCE_UP_MS) return false
const sent = this.sendBestEffort({
t: 'up',
webEligible: false,
commanderEligible: false,
frame: { type: 'app_presence', payload: { kind } },
})
if (sent) this.lastAppPresenceUpAt = now
if (kind === 'open') this.appOpenOwed = !sent
return sent
}
/** Attach one authenticated loopback desktop client to the same RPC and event plane as cloud web. */
registerLocalClient(connId: string, sink: LocalClientSink): boolean {
if (!isLocalClientId(connId) || this.localClients.has(connId)) return false
this.localClients.set(connId, sink)
this.sendAppPresence('open')
return true
}
/** Release all connection-scoped state when the loopback WebSocket closes. */
async unregisterLocalClient(connId: string): Promise<void> {
if (!this.localClients.delete(connId)) return
this.viewerForwarder.closeConnection(connId)
// The window left before any link could hear it attach: nothing happened, as far as the backend
// is concerned, and a later link must not be told otherwise.
if (this.localClients.size === 0) this.appOpenOwed = false
this.downChains.delete(connId)
await this.terminalStreams?.closeConnection(connId, 'local client disconnected', false)
}
/** Route an authenticated local JSON frame through the existing per-client FIFO.
*
* ⚠️ Tagged `'local'`, not left to default to `'relay'`. These frames come from a process on THIS
* machine over the local socket, and until they were tagged they arrived at `dispatchDown`
* indistinguishable from the backend's own — which let any local process send a frame only the
* backend is entitled to send. See the `machine_meta` branch there. */
handleLocalFrame(connId: string, frame: Frame): void {
if (!this.localClients.has(connId)) return
this.enqueueDown(frame, connId, 'local')
}
/** Route an authenticated local terminal frame without applying cloud E2EE. */
async handleLocalBinary(connId: string, frame: TerminalBinaryClear): Promise<void> {
if (!this.localClients.has(connId)) return
await this.terminalStreams?.handleBinary(connId, frame)
}
private enqueue(msg: OutboundEnvelope): void {
const item: QueueItem = { id: this.nextQueueId++, data: JSON.stringify(msg), msg, attempts: 0 }
if (this.queue.length >= QUEUE_MAX) this.dropOneQueued()
if (this.queue.length >= QUEUE_MAX) {
this.droppedSinceLog++
this.logQueueDrops()
return
}
this.queue.push(item)
this.drainQueue()
}
private sendBestEffort(msg: OutboundEnvelope): boolean {
const data = JSON.stringify(msg)
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try { this.ws.send(data); return true } catch { /* ignore */ }
}
return false
}
private sendP2pSignal(connId: string, type: string, payload: TerminalP2pSignal): void {
const frame = this.e2ee.wrapTarget(connId, type, { ...payload })
if (frame) this.sendTo(connId, frame)
}
private handleP2pData(connId: string, data: TerminalP2pData): void {
if (typeof data === 'string') {
if (Buffer.byteLength(data, 'utf8') > 512 * 1024) return
let frame: Frame
try { frame = JSON.parse(data) as Frame } catch { return }
if (typeof frame.type !== 'string' || !TERMINAL_P2P_DOWN_TYPES.has(frame.type)) return
this.enqueueDown(frame, connId, 'p2p')
return
}
if (data.length > 512 * 1024) return
this.enqueueTerminalBinary(connId, data)
}
private noteTerminalInputRoute(
connId: string,
type: string,
payload: Record<string, unknown>,
transport: DownTransport,
): void {
if (type === 'terminal_open' && typeof payload.requestId === 'string') {
let pending = this.p2pPendingOpens.get(connId)
if (!pending) { pending = new Set(); this.p2pPendingOpens.set(connId, pending) }
if (transport === 'p2p') pending.add(payload.requestId)
else pending.delete(payload.requestId)
return
}
const streamId = typeof payload.streamId === 'string' ? payload.streamId : ''
// Live-migration promotion: the client's remoteRelay.ts sends a SECOND terminal_resync over p2p
// (after the first one, over relay, already drained/snapshotted the stream) once its own p2p
// channel is ready — arriving here is our signal to start routing this stream's OUTPUT over p2p
// too, mirroring what the client just did on its side. hasStream() guards against promoting a
// streamId whose pane was closed in the same instant the migration was in flight.
if (type === 'terminal_resync' && transport === 'p2p' && streamId
&& this.terminalStreams?.hasStream(connId, streamId)) {
let streams = this.p2pStreams.get(connId)
if (!streams) { streams = new Set(); this.p2pStreams.set(connId, streams) }
streams.add(streamId)
return
}
if (transport !== 'p2p' && streamId) this.p2pStreams.get(connId)?.delete(streamId)
}
private routeTerminalOutputToP2p(connId: string, type: string, payload: Record<string, unknown>): boolean {
const requestId = typeof payload.requestId === 'string' ? payload.requestId : ''
const streamId = typeof payload.streamId === 'string' ? payload.streamId : ''
const pending = this.p2pPendingOpens.get(connId)
if ((type === 'terminal_ready' || type === 'terminal_error') && requestId && pending?.has(requestId)) {
pending.delete(requestId)
if (pending.size === 0) this.p2pPendingOpens.delete(connId)
if (type === 'terminal_ready' && streamId) {
let streams = this.p2pStreams.get(connId)
if (!streams) { streams = new Set(); this.p2pStreams.set(connId, streams) }
streams.add(streamId)
}
return true
}
const streams = this.p2pStreams.get(connId)
const selected = !!streamId && streams?.has(streamId) === true
if (selected && type === 'terminal_closed') {
streams!.delete(streamId)
if (streams!.size === 0) this.p2pStreams.delete(connId)
}
return selected
}
private demoteP2pConnection(connId: string, reason: string): void {
this.p2pPendingOpens.delete(connId)
this.p2pStreams.delete(connId)
console.warn(`[terminal-p2p] conn=${sid(connId)} fallback=relay reason=${reason}`)
}
private enqueueDown(frame: Frame, connId: string, transport: DownTransport = 'relay'): void {
const key = connId || '__backend__'
const previous = this.downChains.get(key) ?? Promise.resolve()
const next = previous
.catch(() => { /* prior failure is already logged */ })
.then(() => this.dispatchDown(frame, connId, transport))
.catch((err) => {
console.error('[backend] down-frame dispatch failed:', err instanceof Error ? err.message : err)
})
.finally(() => {
if (this.downChains.get(key) === next) this.downChains.delete(key)
})
this.downChains.set(key, next)
}
private enqueueTerminalBinary(connId: string, raw: Uint8Array): void {
const key = connId || '__backend__'
const previous = this.downChains.get(key) ?? Promise.resolve()
const next = previous
.catch(() => { /* prior failure is already logged */ })
.then(async () => {
const clear = this.e2ee.unwrapTerminalBinary(connId, raw)
if (clear) await this.terminalStreams?.handleBinary(connId, clear)
})
.catch((err) => {
console.error('[backend] binary terminal dispatch failed:', err instanceof Error ? err.message : err)
})
.finally(() => {
if (this.downChains.get(key) === next) this.downChains.delete(key)
})
this.downChains.set(key, next)
}
private drainQueue(): void {
if (this.draining || !this.queue.length) return
const ws = this.ws
if (!ws || ws.readyState !== WebSocket.OPEN) return
const item = this.queue[0]
this.draining = true
try {
ws.send(item.data, (err?: Error) => {
if (this.ws !== ws) return
if (err) {
item.attempts++
this.draining = false
console.error(`[backend] queued send failed (id=${item.id}, attempts=${item.attempts}):`, err.message)
try { ws.close() } catch { /* ignore */ }
return
}
if (this.queue[0] === item) this.queue.shift()
this.draining = false
this.drainQueue()
})
} catch (err) {
item.attempts++
this.draining = false
console.error(`[backend] queued send threw (id=${item.id}, attempts=${item.attempts}):`, err instanceof Error ? err.message : err)
try { ws.close() } catch { /* ignore */ }
}
}
private dropOneQueued(): void {
const idx = this.queue.findIndex((item) => !('targetConnId' in item.msg))
const dropAt = idx >= 0 ? idx : 0
if (this.queue.splice(dropAt, 1).length) {
this.droppedSinceLog++
this.logQueueDrops()
}
}
private logQueueDrops(): void {
if (this.droppedSinceLog === 1 || this.droppedSinceLog % 100 === 0) {
console.warn(`[backend] outbound queue full; dropped ${this.droppedSinceLog} frame(s) so far`)
}
}
// ── down-frame dispatch (the hosted runtime-role RPC switch) ────────────────────────────────────────────
/** Emit an RPC reply. For an E2EE-session requester whose result carries user content, the reply is
* encrypted with that connection's session key and delivered ONLY to it. Content-bearing adapter data
* is never returned plaintext: even legacy backend nodeRequest (`connId === ''`) gets only an error. */
private emitReply(connId: string, type: string, requestId: unknown, payload: Record<string, unknown>): void {
const resultType = `${type}_result`
// Before the E2EE wrap: an RPC reply is only readable here.
if (env.LOG_FRAMES && !type.startsWith('grid_fleet_') && type !== 'agent_read_file' && type !== 'project_preview' && !SHARE_REQUEST_TYPES.has(type)) logFrame('→', connId ? `conn:${sid(connId)}` : 'backend', { type: resultType, payload: { requestId, ...payload } })
if (this.localClients.has(connId)) {
this.sendTo(connId, { type: resultType, payload: { requestId, ...payload } })
return
}
if (connId && this.e2ee.hasSession(connId) && encryptRpcResult(resultType)) {
let replyPayload = payload
if (resultType === 'agent_recent_result') {
const trim = fitRecentReplyPayloadForDevice(
payload,
(candidate) => this.e2ee.rpcReplyFrameBytes(connId, resultType, requestId, candidate),
)
replyPayload = trim.payload
if (trim.trimmed) {
console.warn(
`[recent-trim] agent=${String(payload.agentId ?? '')} originalFrame=${trim.originalBytes ?? 'unknown'} ` +
`finalFrame=${trim.finalBytes ?? 'unknown'} target=${DEVICE_RECENT_SAFE_FRAME_BYTES} ` +
`textBytes=${trim.textBytes} recapBytes=${trim.recapBytes}`,
)
}
}
const wrapped = this.e2ee.wrapRpcReply(connId, resultType, requestId, replyPayload)
if (wrapped) { this.sendTo(connId, wrapped); return }
}
// Enforcement ("no E2EE ⇒ no adapter data"): content-bearing RPC replies must never leave the
// adapter plaintext. A real client gets a targeted error; the legacy backend nodeRequest awaiter
// (`connId === ''`) gets a broadcast error with the same requestId so it fails closed without data.
//
if (encryptRpcResult(resultType)) {
const errorFrame = { type: resultType, payload: { requestId, error: 'E2EE_REQUIRED' } }
if (connId) this.sendTo(connId, errorFrame)
else this.send(errorFrame)
return
}
this.send({ type: resultType, payload: { requestId, ...payload } })
}
private async dispatchDown(frame: Frame, connId: string, transport: DownTransport = 'relay'): Promise<void> {
const type = frame.type as string | undefined
if (!type) return
// Whether this frame came from a process on THIS machine — the trust boundary the gates below
// turn on. The membership half is a dispatch-time question about a connection that may already
// be gone: frames run through a per-connId queue, so a local client that disconnects between
// sending and being dispatched used to leave `localClients.has()` false, and its already-queued
// frames were then read as the BACKEND's. The transport half closes that, because it is stamped
// at enqueue by the caller that had just verified membership. Either one being true is local.
const local = transport === 'local' || this.localClients.has(connId)
// ⚠️ The backend's own instructions, refused from anywhere else. See BACKEND_ONLY_DOWN_TYPES.
// `transport === 'relay'` rather than `!local`, so this keeps holding if the p2p allowlist
// (`TERMINAL_P2P_DOWN_TYPES`) ever widens; "not local" would quietly start admitting p2p.
//
// Deliberately ABOVE the observer hand-off below. A genuine observer frame is `relay`, so this
// never intercepts one; but placed after it, a forged `observer:` connId on a local or p2p frame
// would be swallowed by `harnessSharing.receive` and returned without ever reaching this line —
// silently, with no warning — and the invariant would then rest on three facts in other files
// (the `local:` prefix rule, `registerLocalClient`'s check, the p2p-signal ordering) instead of
// on this one. The grid-name incident was exactly a bypass nobody could see.
if (transport !== 'relay' && BACKEND_ONLY_DOWN_TYPES.has(type)) {
console.warn(`[backend] ignoring ${type} from ${transport} (${connId}) — only the backend may send it`)
return
}
if (connId.startsWith('observer:')) {
await this.harnessSharing?.receive(connId, type, (frame.payload ?? {}) as Record<string, unknown>)
return
}
if (type.startsWith('observer_')) return
// E2EE control frames (pairing/handshake) are handled by the manager, never as node RPCs.
if (type.startsWith('e2e_')) {
if (local) {
this.sendTo(connId, { type: 'local_protocol_error', payload: { error: 'LOCAL_E2EE_UNSUPPORTED' } })
return
}
this.e2ee.handleFrame(connId, frame)
return
}
if (type === 'autonomous_device_request') {
if (!local) await this.autonomousDeviceRelay?.handle(connId, frame)
return
}
// Client→adapter encrypted frames: chat messages plus trusted web control actions. Plaintext
// passes through for legacy/device transition paths; undecryptable ciphertext is dropped.
if (!local && encryptDownFrame(type)) {
if (type !== 'message' && type !== 'question_response' && !isWrapped(frame.payload)) {
const requestId = (frame.payload as { requestId?: unknown } | undefined)?.requestId
if (requestId !== undefined) this.emitReply(connId, type, requestId, { error: 'E2EE_REQUIRED' })
return
}
const dec = this.e2ee.unwrapDown(connId, frame)
if (!dec) return
frame = dec
}
if (!local && TERMINAL_P2P_SIGNAL_TYPES.has(type)) {
await this.terminalP2p.handleSignal(connId, type, frame.payload)
return
}
// Logged AFTER the unwrap above, so a down-frame reads as what the client actually asked for rather
// than as an opaque __e2e envelope.
// Terminal frames contain raw keystrokes, paste text and screen bytes after
// unwrap. Never pass them to the frame logger, even in diagnostic mode.
if (env.LOG_FRAMES && !type.startsWith('terminal_') && !type.startsWith('viewer_') && !type.startsWith('grid_fleet_') && type !== 'agent_read_file' && type !== 'project_preview' && type !== 'orchestrator' && !SHARE_REQUEST_TYPES.has(type)) {
logFrame('←', connId ? `conn:${sid(connId)}` : 'backend', frame)
}
const reply = (t: string, rid: unknown, p: Record<string, unknown>): void => this.emitReply(connId, t, rid, p)
if (SHARE_REQUEST_TYPES.has(type)) {
const p = (frame.payload ?? {}) as Record<string, unknown>
const result = await this.harnessSharing?.manage(type, p).catch(() => ({ error: 'SHARING_UNAVAILABLE', detail: 'Sharing is temporarily unavailable. Try again.' }))
reply(type, p.requestId, result ?? { error: 'UNSUPPORTED' })
return
}
// Cross-instance client snapshot. Generation detects leave/join cycles that coalesce to the same
// count; count rise remains the compatibility fallback for older backends.
if (type === '__clients') {
const payload = (frame.payload ?? {}) as { commander?: number; commanderActive?: number; commanderJoinGeneration?: number }
const commander = Number(payload.commander ?? 0)
const rawGeneration = payload.commanderJoinGeneration
const generation = typeof rawGeneration === 'number' && Number.isSafeInteger(rawGeneration) && rawGeneration >= 0
? rawGeneration
: undefined
const replay = shouldReplayCommander(
this.commanderCount,
commander,
this.replayedCommanderGeneration,
generation,
this.replayCommanderOnNextSnapshot,
)
this.setCommanderCount(commander, payload.commanderActive != null ? Number(payload.commanderActive) : null)
if (replay) {
this.replayCommanderOnNextSnapshot = false
if (generation != null) this.replayedCommanderGeneration = generation
this.onCommanderJoin?.()
}
return
}
if (type === '__client_disconnected') {
// The backend is authoritative for the outer connId. Drop both kinds of
// connection-scoped state immediately; otherwise a dead Desktop keeps a
// terminal controller lease until the 30-second heartbeat timeout.
this.autonomousDeviceRelay?.drop(connId)
this.e2ee.dropSession(connId)
this.viewerForwarder.closeConnection(connId)
await this.terminalP2p.closeConnection(connId, 'client_disconnected', false)
this.p2pPendingOpens.delete(connId)
this.p2pStreams.delete(connId)
await this.terminalStreams?.closeConnection(
connId,
'client connection closed',
false,
)
return
}
// Other backend-hub internal control frames (__clients_dirty) — not for us; drop silently.
if (type.startsWith('__')) return
// The machine was deleted/revoked from the web → stop for good (don't reconnect) and let the CLI
// clear the saved token. `closed` blocks the reconnect that would otherwise fire on socket drop.
if (type === 'machine_revoked') { this.closed = true; this.onRevoked?.(); return }
// Machine display name (seed on connect + web renames) — mirrored locally for `harness status`.
if (type === 'machine_meta') {
// ⚠️ The BACKEND's frame and nobody else's. It carries this machine's display name and, more
// to the point, the account's private grid — the grid every agent on this computer is then
// pointed at. Accepted from any transport, it let a process that could open the daemon's local
// port redirect the account's inference somewhere of its choosing, and a leftover test script
// doing exactly that by accident cost hours to find. No client sends this frame; there is
// nothing to be compatible with.
// The source check is above, with the other frames only the backend may send.
//
// A malformed/hostile frame's payload need not be an object; `'gridName' in meta` would throw
// on a primitive (and drop the whole frame via enqueueDown's catch). Guard the type first, the
// way the plain property reads elsewhere in this dispatcher tolerate one.
const meta = (typeof frame.payload === 'object' && frame.payload !== null ? frame.payload : {}) as { name?: unknown; gridName?: unknown }
const name = meta.name
// The account's private grid, pushed on connect. Held in memory only: it is the backend's
// value, and a daemon that cached it on disk would keep answering with a stale one after the
// account's grid changed. Only ACT on the key when it is present: the connect frame always
// carries it (a string or null), but a rename pushes `{name}` alone — and treating that
// absence as null used to WIPE a grid name a moment after it was set, leaving the picker
// empty. Absent ⇒ unchanged; null ⇒ this account has none; a string ⇒ that grid.
if ('gridName' in meta) {
this.harnessGridName = typeof meta.gridName === 'string' && meta.gridName.trim() ? meta.gridName.trim() : null
}
this.onMachineMeta?.(typeof name === 'string' && name.trim() ? name.trim() : null)
return
}
const payload = (frame.payload ?? {}) as Record<string, unknown>
const requestId = payload.requestId
// Same-host only until remote viewer transport and remote task ownership exist.
// Refuse before parsing project content; never send it to the relay as plaintext.
if (type === 'orchestrator') {
if (!local) { reply(type, requestId, { error: 'LOCAL_ONLY', detail: 'Orchestrator projects run on the local machine.' }); return }
// Detached: a large artifact snapshot must not block cancel/status on this connection.
void orchestratorRequest(this.orchestration(), payload)
.then(result => reply(type, requestId, result))
.catch(() => reply(type, requestId, { error: 'ORCHESTRATOR_FAILED' }))
return
}
if (type.startsWith('viewer_')) {
if (VIEWER_DOWN_TYPES.has(type) && (local || this.e2ee.sessionRole(connId) === 'web')) {
this.viewerForwarder.handle(connId, type, payload)
}
return
}
if (type.startsWith('terminal_')) {
this.noteTerminalInputRoute(connId, type, payload, transport)
if (this.terminalStreams) await this.terminalStreams.handleFrame(connId, type, payload)
return
}
try {
switch (type) {
case 'grid_fleet_capabilities':
reply(type, requestId, { protocol: GRID_FLEET_PROTOCOL, gridCli: gridCliPresence(), maxTimeoutMs: GRID_FLEET_MAX_TIMEOUT_MS, thinkingControl: true })
return
case 'grid_fleet_run': {
const request = parseGridFleetRequest(payload)
if (!request || typeof requestId !== 'string') { reply(type, requestId, { error: 'INVALID_GRID_COMMAND' }); return }
// Detached: pulls/builds can take minutes. Keep typing, cancellation, and telemetry responsive.
void this.gridFleet.run(connId, requestId, request)
.then(result => reply(type, requestId, { ...result }))
.catch(() => reply(type, requestId, { ok: false, code: 1, error: 'Grid command failed unexpectedly.' }))
return
}
case 'grid_fleet_cancel':
reply(type, requestId, { cancelled: typeof payload.commandId === 'string' && this.gridFleet.cancel(connId, payload.commandId) })
return
case 'device_e2ee_pair':
await this.e2ee.pairDeviceFromTrustedWeb(connId, payload)
return
case 'e2ee_pairings_list':
reply(type, requestId, { pairs: this.e2ee.listPaired(connId) })
return
case 'e2ee_pairing_unpair':
this.e2ee.revokeFromTrustedWeb(connId, payload)
return
case 'e2ee_pairings_unpair_all':
this.e2ee.revokeAllFromTrustedWeb(connId, requestId)
return
case 'e2ee_browser_link_create': {
const setup = this.e2ee.createSetupToken()
reply(type, requestId, setup)
return
}
case 'agents_list': {
const projects = await Promise.all(registry.advertised().map((s) => this.toProject(s)))
// Ordered by creation time, oldest → newest — a stable tab order that doesn't reshuffle as
// sessions become active (createdAt = the session's registeredAt). The id breaks a tie so the
// order is TOTAL: without it two agents registered in the same millisecond fall through to array
// position, which is Map insertion order and differs between daemon runs — the web, the app and
// the dial would each show a different order for the same registry. `cableHost.listAgents`
// sorts by the same rule; the two must stay identical.
projects.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || a.id.localeCompare(b.id))
if (this.e2ee.sessionRole(connId) === 'device') {
reply(type, requestId, { agents: projects.filter(deviceAgentRow).slice(0, DEVICE_AGENT_LIST_LIMIT).map(deviceAgentListItem) })
return
}
reply(type, requestId, { agents: projects })
return
}
case 'sessions_list': {
const projectId = payload.agentId as string | undefined
if (!projectId) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
const s = registry.resolve(projectId)
// An agent whose engine has not reported a session yet has no transcript to list. Saying so
// plainly beats inventing one: the web then shows the tab with an empty thread until the bind
// lands, instead of pinning `currentSessionId` to an id no event will ever carry.
if (!s || !s.sessionId) { reply(type, requestId, { sessions: [] }); return }
const lines = s.transcriptPath ? await tailFile(s.transcriptPath, Infinity) : []
const st = s.transcriptPath ? await stat(s.transcriptPath).catch(() => null) : null
reply(type, requestId, {
sessions: [{
id: s.sessionId,
title: projectDisplayName(s),
timestamp: new Date(s.registeredAt).toISOString(),
messageCount: lines.length,
lastActivity: new Date(st?.mtimeMs ?? s.updatedAt).toISOString(),
participants: [],
}],
})
return
}
case 'session_get': {
const sessionId = payload.sessionId as string | undefined
if (!sessionId) { reply(type, requestId, { error: 'MISSING_SESSION_ID' }); return }
// Only serve transcripts for a REGISTERED tmux session, read from its own trusted
// transcriptPath — never resolve an arbitrary request-supplied id to a file (that let a
// caller read any *.jsonl on the computer, incl. unshared claude history / traversal).
const s = registry.resolve(sessionId)
if (!s) { reply(type, requestId, { error: 'NOT_FOUND' }); return }
if (s.engine === 'devin') {
// Devin history comes from its SQLite store (no transcript file). Same replay/window shape.
const rawLimit = payload.limit
const limit = typeof rawLimit === 'number' && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : undefined
const before = typeof payload.before === 'string' ? payload.before : undefined
const messages = await readDevinMessages(DEVIN_DB, sessionId)
const timestamp = new Date(s.updatedAt).toISOString()
if (!limit) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: devinMessagesToEvents(messages), timestamp, engine: s.engine })
return
}
const w = windowDevinMessages(messages, { limit, before })
if (w.staleCursor) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: [], timestamp, engine: s.engine, hasMore: false, oldestCursor: null, staleCursor: true })
return
}
const events = devinMessagesToEvents(w.window)
if (before && events[events.length - 1]?.type === 'done') events.pop()
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events, timestamp, engine: s.engine, hasMore: w.hasMore, oldestCursor: w.oldestCursor })
return
}
if (s.engine === 'hermes') {
// Hermes history lives in its SQLite store (no transcript file). Same replay/window shape.
const rawLimit = payload.limit
const limit = typeof rawLimit === 'number' && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : undefined
const before = typeof payload.before === 'string' ? payload.before : undefined
const messages = await readHermesMessages(HERMES_DB, sessionId)
const timestamp = new Date(s.updatedAt).toISOString()
if (!limit) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: hermesMessagesToEvents(messages), timestamp, engine: s.engine })
return
}
const w = windowHermesMessages(messages, { limit, before })
if (w.staleCursor) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: [], timestamp, engine: s.engine, hasMore: false, oldestCursor: null, staleCursor: true })
return
}
const events = hermesMessagesToEvents(w.window)
if (before && events[events.length - 1]?.type === 'done') events.pop()
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events, timestamp, engine: s.engine, hasMore: w.hasMore, oldestCursor: w.oldestCursor })
return
}
if (s.engine === 'opencode') {
// OpenCode history comes from its SQLite DB (no transcript file). Same replay/window shape.
const rawLimit = payload.limit
const limit = typeof rawLimit === 'number' && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : undefined
const before = typeof payload.before === 'string' ? payload.before : undefined
const messages = await readOpencodeMessages(OPENCODE_DB, sessionId)
const timestamp = new Date(s.updatedAt).toISOString()
if (!limit) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: opencodeMessagesToEvents(messages), timestamp, engine: s.engine })
return
}
const w = windowOpencodeMessages(messages, { limit, before })
if (w.staleCursor) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: [], timestamp, engine: s.engine, hasMore: false, oldestCursor: null, staleCursor: true })
return
}
const events = opencodeMessagesToEvents(w.window)
if (before && events[events.length - 1]?.type === 'done') events.pop()
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events, timestamp, engine: s.engine, hasMore: w.hasMore, oldestCursor: w.oldestCursor })
return
}
if (s.engine === 'kilo') {
// Kilo history likewise comes from its SQLite DB. BOTH branches below are load-bearing: the
// web client always sends a `limit`, so handling only the full-transcript one opens an empty
// pane — the exact half-dispatch this file has been caught on before. Cursors are namespaced
// `kilo:<index>` by `windowKiloMessages`, so a cursor from another engine reads as stale
// rather than silently indexing into the wrong conversation.
const rawLimit = payload.limit
const limit = typeof rawLimit === 'number' && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : undefined
const before = typeof payload.before === 'string' ? payload.before : undefined
const messages = await readKiloMessages(KILO_DB, sessionId)
const timestamp = new Date(s.updatedAt).toISOString()
if (!limit) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: kiloMessagesToEvents(messages), timestamp, engine: s.engine })
return
}
const w = windowKiloMessages(messages, { limit, before })
if (w.staleCursor) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: [], timestamp, engine: s.engine, hasMore: false, oldestCursor: null, staleCursor: true })
return
}
const events = kiloMessagesToEvents(w.window)
if (before && events[events.length - 1]?.type === 'done') events.pop()
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events, timestamp, engine: s.engine, hasMore: w.hasMore, oldestCursor: w.oldestCursor })
return
}
if (!s.transcriptPath) {
reply(type, requestId, {
id: sessionId,
title: projectDisplayName(s),
events: [],
timestamp: new Date(s.updatedAt).toISOString(),
engine: s.engine,
hasMore: false,
oldestCursor: null,
})
return
}
// Optional pagination: `limit` = window size; `before` = uuid cursor (oldest line the
// client already holds). Absent → full transcript (legacy). Clamp limit defensively.
const rawLimit = payload.limit
const limit = typeof rawLimit === 'number' && rawLimit > 0 ? Math.min(Math.floor(rawLimit), 500) : undefined
const before = typeof payload.before === 'string' ? payload.before : undefined
const lines = await tailFile(s.transcriptPath, Infinity)
const st = await stat(s.transcriptPath).catch(() => null)
const timestamp = new Date(st?.mtimeMs ?? Date.now()).toISOString()
if (!limit) {
const fullEvents = s.engine === 'codex'
? codexMessagesToEvents(lines, codexSubagentResolverFor(s.codexHome))
: s.engine === 'cursor'
? cursorMessagesToEvents(lines, sessionId, await loadCursorReplayTaskLinks(env.CURSOR_HOME, sessionId))
: s.engine === 'muse'
? museMessagesToEvents(lines)
: s.engine === 'amp'
? await ampHistory(sessionId, lines)
: s.engine === 'grok'
? grokHistoryPage(lines, false).events
: s.engine === 'agy'
? agyHistoryPage(lines, false).events
: s.engine === 'copilot'
? copilotHistoryPage(lines, false).events
: s.engine === 'pi'
? piMessagesToEvents(lines)
: s.engine === 'commandcode'
? commandcodeMessagesToEvents(lines)
: messagesToEvents(lines)
if (s.engine !== 'cursor' && s.engine !== 'pi' && s.engine !== 'commandcode' && s.engine !== 'muse' && s.engine !== 'amp' && s.engine !== 'grok' && s.engine !== 'agy' && s.engine !== 'copilot') await enrichSubagentStats(fullEvents, s.transcriptPath)
reply(type, requestId, {
id: sessionId,
title: projectDisplayName(s),
events: fullEvents,
timestamp,
engine: s.engine,
})
return
}
// Muse has no windower, so it must not fall through to the raw one: that pairs claude's
// line-uuid cursor with claude's normalizer, and a muse transcript comes back EMPTY — the web
// pane opened blank with no error anywhere. Until a muse window exists, answer the page with
// the whole transcript (`hasMore: false` ends the scroll honestly, and these sessions are
// small: a real one measured 271 lines).
// Amp is in the same position as muse and for the same reason: no windower, so falling
// through would pair claude's line-uuid cursor with claude's normalizer and return nothing.
if (s.engine === 'muse' || s.engine === 'amp' || s.engine === 'grok' || s.engine === 'agy' || s.engine === 'copilot') {
const wholePage = s.engine === 'grok'
? grokHistoryPage(lines, true)
: s.engine === 'agy'
? agyHistoryPage(lines, true)
: s.engine === 'copilot'
? copilotHistoryPage(lines, true)
: null
reply(type, requestId, {
id: sessionId,
title: projectDisplayName(s),
events: s.engine === 'amp'
? await ampHistory(sessionId, lines)
: wholePage
? wholePage.events
: museMessagesToEvents(lines),
timestamp,
engine: s.engine,
hasMore: wholePage?.hasMore ?? false,
oldestCursor: wholePage?.oldestCursor ?? null,
})
return
}
const w = s.engine === 'codex'
? windowCodexLines(lines, { limit, before })
: s.engine === 'cursor'
? windowCursorLines(lines, { limit, before })
: s.engine === 'pi'
? windowPiLines(lines, { limit, before })
: s.engine === 'commandcode'
? windowCommandCodeLines(lines, { limit, before })
: windowRawLines(lines, { limit, before })
if (w.staleCursor) {
reply(type, requestId, { id: sessionId, title: projectDisplayName(s), events: [], timestamp, engine: s.engine, hasMore: false, oldestCursor: null, staleCursor: true })
return
}
const events = s.engine === 'codex'
? codexMessagesToEvents(w.window, codexSubagentResolverFor(s.codexHome))
: s.engine === 'cursor'
? cursorMessagesToEvents(
w.window,
sessionId,
await loadCursorReplayTaskLinks(env.CURSOR_HOME, sessionId),
'startIndex' in w && typeof w.startIndex === 'number' ? w.startIndex : 0,
'initialTodos' in w && Array.isArray(w.initialTodos) ? w.initialTodos : [],
)
: s.engine === 'pi'
? piMessagesToEvents(w.window)
: s.engine === 'commandcode'
? commandcodeMessagesToEvents(w.window)
: messagesToEvents(w.window)
// muse and amp are answered above and never reach here, so both are absent by design.
if (s.engine !== 'cursor' && s.engine !== 'pi' && s.engine !== 'commandcode') await enrichSubagentStats(events, s.transcriptPath)
// Older pages must not inject a spurious end-of-transcript marker mid-scroll.
if (before && events[events.length - 1]?.type === 'done') events.pop()
reply(type, requestId, {
id: sessionId,
title: projectDisplayName(s),
events,
timestamp,
engine: s.engine,
hasMore: w.hasMore,
oldestCursor: w.oldestCursor,
})
return
}
case 'grid_models_list': {
// Every grid this computer is signed into, in sections, the account's own first. `gridName`
// and `models` keep naming the own grid alone, for an app that predates `grids`.
//
// DETACHED from this connection's ordered RPC chain, like `engines_probe`: it waits on a
// grid reconcile (up to 6s), then `grid` spawns that each go to the network (up to 30s).
// The desktop asks for it in the same breath as `terminal_capabilities` and `agents_list`
// on every connect, and awaited here it held both behind it — with no network, past the
// app's 10s request timeout, on which the app forces a reconnect and asks all three again.
// Measured 2026-09-18, wifi off, daemon restarted: every local RPC timed out for as long
// as the backend stayed unreachable; the terminal on the SAME computer sat on "offline"
// until the wifi came back. Request ids make the reply safe to land out of order.
//
// One computation at a time: detached, a second ask that lands while the first is still
// out (the app re-asks on every connect, and its own 12s timeout is shorter than the grid
// spawns' 30s) would start more `grid` processes for the same answer. Later askers share
// the one in flight; the cache in listGridModels covers the settled case.
void (async () => {
const gridName = await this.resolveGridName()
const inFlight = this.gridModelsInFlight
const listing = inFlight && inFlight.gridName === gridName
? inFlight.grids
: (this.gridModelsInFlight = {
gridName,
grids: listAllGridModels(gridName).finally(() => {
if (this.gridModelsInFlight?.gridName === gridName) this.gridModelsInFlight = null
}),
}).grids
const grids = await listing
reply(type, requestId, {
gridName,
models: grids.find((g) => g.own)?.models ?? [],
grids,
// Which engines a Local model can be offered to at all. Static per CLI version — it is
// the set of launch contracts in `gridLaunch.ts` — and answered here, beside the list,
// so the picker can say "Cursor runs only on its own login" instead of offering a row
// whose retarget the daemon would refuse. An older app ignores the field; an older
// daemon omits it, which the app reads as "offer everything", as before.
localModelEngines: gridCapableEngines(),
// Whether this MACHINE has a `grid` to run at all — `managed`, `path` or `missing` —
// as distinct from `gridName`, which is about the account. The Local model dialog was
// gating on the account alone and starting an agent whose second step is `grid`; this
// is what lets it, and the picker, say so first. An older app ignores the field.
gridCli: gridCliPresence(),
})
})().catch(() => reply(type, requestId, { error: 'GRID_MODELS_FAILED' }))
return
}
case 'models_list': {
const sessionId = typeof payload.agentId === 'string' && payload.agentId
? payload.agentId
: undefined
const models = this.runtimeModelsProvider
? await this.runtimeModelsProvider(sessionId)
: [{ id: 'default', displayName: 'Remote CLI default' }]
reply(type, requestId, {
// The device derives labels from the opaque runtime-v1 id. Omitting the duplicate
// displayName keeps the encrypted picker response below its 16 KiB decrypt cap.
models: payload.compact === true
? compactRuntimePickerModels(models, sessionId, payload.pickerMode, payload.selectedModel)
: models,
})
return
}
case 'dsh_list': {
// Keep catalog I/O off this connection's ordered RPC queue.
void refreshDshRegistry()
.then(catalog => reply(type, requestId, { dsh: dshListRows(undefined, catalog) }))
.catch(error => reply(type, requestId, { error: 'INTERNAL', detail: error instanceof Error ? error.message : String(error) }))
return
}
case 'dsh_remove': {
// Uninstall a harness from THIS machine: the clone under ~/.harness/dsh goes (a linked
// install loses only its link), the index forgets it, and a `dsh_list` after this no
// longer says installed. Agents already running from it keep running — their processes
// hold what they need — and the store is what asks; it refreshes the list itself.
if (!this.onDshRemove) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const id = dshRemoveId(payload)
if (!id) { reply(type, requestId, { error: 'INVALID_DSH', detail: 'dsh_remove needs an id' }); return }
reply(type, requestId, dshRemoveReply(id, this.onDshRemove(id)))
return
}
case 'remote_terminal_handoff': {
// `harness remote`, typed INSIDE one of this machine's terminal tiles, opened a terminal on
// another machine and asks the window showing the tile to swap it over: the tile it was
// typed in (named by its tmux pane, the one fact the shell has about itself) becomes the new
// agent's, and the old shell is ended by the window. Asked over loopback only (the command
// runs on this machine), but PUSHED to every audience: the window showing a tile of this
// machine may be on another computer, reached through the relay — nothing in the payload
// but ids, so it travels plain like dsh_install_status.
if (!this.localClients.has(connId)) { reply(type, requestId, { error: 'UNSUPPORTED' }); return }
const handoff = terminalHandoffRequest(payload)
if (!handoff) { reply(type, requestId, { error: 'INVALID_HANDOFF', detail: 'remote_terminal_handoff needs tmuxPane (%N), machineId and agentId' }); return }
const fromAgentId = this.onTerminalHandoff?.(handoff.tmuxPane) ?? null
if (!fromAgentId) { reply(type, requestId, { error: 'NOT_A_HARNESS_PANE', detail: `${handoff.tmuxPane} is not a Harness terminal on this machine` }); return }
// Who could hear it: other loopback clients (a window on this computer) and the web audience
// (a window elsewhere, relayed). None means nobody is here to swap the tile.
const windows = [...this.localClients.keys()].filter((id) => id !== connId).length + this.commanderCount
this.send({ type: 'remote_terminal_handoff', payload: { fromAgentId, machineId: handoff.machineId, agentId: handoff.agentId } })
reply(type, requestId, { ok: true, fromAgentId, windows })
return
}
case 'dsh_update': {
if (!this.onDshUpdate) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const id = dshRemoveId(payload)
if (!id) { reply(type, requestId, { error: 'INVALID_DSH', detail: 'dsh_update needs an id' }); return }
void this.onDshUpdate(id, p => this.send({ type: 'dsh_install_status', payload: dshInstallStatus(p, { id }) }))
.then(result => reply(type, requestId, dshInstallReply(result)))
.catch(error => reply(type, requestId, { error: 'INTERNAL', detail: error instanceof Error ? error.message : String(error) }))
return
}
case 'dsh_install': {
// Clone, set up and doctor a harness on THIS machine. Long — minutes, for a toolchain — so
// it is detached from the ordered RPC chain like `engines_probe`, and progress travels as
// `dsh_install_status` pushes the app renders in the create dialog.
if (!this.onDshInstall) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const request = dshInstallRequest(payload)
if (!request) { reply(type, requestId, { error: 'INVALID_DSH', detail: 'dsh_install needs an id or a url' }); return }
void this.onDshInstall(request, (p) => this.send({ type: 'dsh_install_status', payload: dshInstallStatus(p, request) }))
.then((result) => reply(type, requestId, dshInstallReply(result)))
.catch((error) => reply(type, requestId, { error: 'INTERNAL', detail: error instanceof Error ? error.message : String(error) }))
return
}
case 'engines_probe': {
// Which engines this machine has, asked BEFORE a create rather than discovered by one
// failing. Answered here — on the machine in question — because a Mac and the Docker rig
// routinely hold different engines, and an availability list computed anywhere else is
// wrong for exactly the remote case this request exists to serve.
//
// `engines` narrows the probe to what the caller is showing; an absent or malformed list
// means "all of them", so an older app that sends nothing still gets a usable answer.
const asked = Array.isArray(payload.engines)
? payload.engines.filter((id): id is AgentEngine =>
typeof id === 'string' && (ENGINES as readonly string[]).includes(id))
: undefined
// A full probe starts interactive login shells and is intentionally detached from this
// connection's ordered RPC chain. Request ids make its eventual reply safe to deliver out
// of order; keeping it awaited here made a Create click sit behind an unrelated sweep.
void this.engineProbeProvider(asked && asked.length > 0 ? asked : undefined)
.then((availability) => reply(type, requestId, {
engines: availability.map((entry) => ({
engine: entry.engine,
installed: entry.installed,
command: entry.command,
installable: entry.installable,
installCommand: entry.installable ? engineInstallRecipe(entry.engine)?.command ?? null : null,
// Static per-CLI-version capability, not a probe result: its mere presence is what
// lets an older CLI (which never sends the field) keep reading as "unknown" rather
// than "no", per the desktop app's `EngineAvailability.fromJson`.
...(entry.engine === 'codex' ? { supportsCodexHome: true } : {}),
})),
}))
.catch(() => reply(type, requestId, { error: 'ENGINE_PROBE_FAILED' }))
return
}
case 'agent_recent': {
// Device tile restore at boot: the session's persisted LLM turn-summary (recap + body),
// mirroring the hosted runtime’s SessionService.getRecentEvents. Empty until a turn was summarized
// (device-gated) — never a resurrected full-text card.
const projectId = payload.agentId as string | undefined
if (!projectId) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
const n = Math.max(1, Math.min(5, Number(payload.n) || 2))
const events = this.recentProvider ? this.recentProvider(projectId, n) : []
// ASKS TRAVEL AS THEIR OWN LIST, beside the events rather than inside them. A question exists
// the moment it is asked; a recap exists once the turn has been answered and summarised. They
// are different lengths on any machine where a turn ended without one, so a reply that folds
// the questions into the event rows loses exactly the newest ones — and a REMOTE agent then
// reaches the router with nothing but its name.
const asks = this.recentAsksProvider ? this.recentAsksProvider(projectId, n) : []
reply(type, requestId, { agentId: projectId, events, asks })
return
}
case 'voice_route': {
// Voice router (REMOTE machine): pick the best-fit agent for a transcribed Overview voice task,
// using each agent's name + recent-turn recap. Backend-originated (connId===''), so the transcript
// is plaintext and the reply falls through emitReply's plaintext path (voice_route is not E2EE-gated).
const transcript = typeof payload.transcript === 'string' ? payload.transcript : ''
if (!transcript.trim()) {
console.log('[voice-route] backend sent an empty transcript — nothing to route')
reply(type, requestId, { error: 'MISSING_TRANSCRIPT' })
return
}
const projects = (await Promise.all(registry.advertised().map((s) => this.toProject(s)))).slice(0, 24) // cap the router prompt (mirror the hosted runtime path)
const agents = projects.map((p) => {
// Use the last 3 turns' recaps (not just 1) — a single turn can misrepresent what the agent is
// actually working on; 3 gives the router a more stable picture.
const recents = this.recentProvider?.(p.id, 3) ?? []
const recentSummary = recents.map((r) => r?.recap || r?.text || '').filter(Boolean).join(' · ')
// engine: the router classifies with a CLI the machine actually runs (a live agent proves it is
// installed and logged in), so it has to travel with the agent.
const session = registry.resolve(p.id)
return { id: p.id, name: p.name, recentSummary, engine: session?.engine, gateway: session?.gateway }
})
const decision = await routeVoiceTask(transcript, agents) // logs the task, candidates and pick
const chosen = projects.find((p) => p.id === decision.agentId)
reply(type, requestId, { ...decision, agentName: chosen?.name ?? '' })
return
}
case 'agent_update': {
const projectId = payload.agentId as string | undefined
if (!projectId) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
const hasName = Object.prototype.hasOwnProperty.call(payload, 'name')
const hasProfile = Object.prototype.hasOwnProperty.call(payload, 'selectedModel')
if (!hasName && !hasProfile) { reply(type, requestId, { error: 'MISSING_UPDATE' }); return }
const name = typeof payload.name === 'string' ? payload.name.trim() : ''
if (hasName && !name) { reply(type, requestId, { error: 'MISSING_NAME' }); return }
let s = registry.resolve(projectId)
if (!s) { reply(type, requestId, { error: 'AGENT_NOT_FOUND' }); return }
if (hasProfile) {
if (typeof payload.selectedModel !== 'string' || !this.onRuntimeProfileUpdate) {
reply(type, requestId, { error: 'INVALID_RUNTIME_PROFILE' })
return
}
try {
await this.onRuntimeProfileUpdate(projectId, payload.selectedModel)
} catch (error) {
const code: RuntimeProfileErrorCode | 'INTERNAL' = error instanceof RuntimeProfileControlError ? error.code : 'INTERNAL'
reply(type, requestId, { error: code })
return
}
}
if (hasName) {
s = registry.rename(projectId, name) ?? s
this.onAgentRename?.(s, name)
}
const agent = await this.toProject(s)
reply(type, requestId, { agent })
if (hasName) {
const renamed = { type: 'agent_renamed', payload: { agentId: s.agentId, name, engine: s.engine } }
this.send(renamed) // every OTHER web client on this machine (group-encrypted)
this.sendCommander(renamed) // and the device
// The whole rename path was silent end to end, which is why "web1 renamed it, web2 never saw
// it" had no evidence to work from: nothing said whether the request even arrived. One line
// here splits the question in two — no line means it never reached the adapter, a line means
// the fan-out is downstream.
console.log(`[rename] ${sid(projectId)} → "${preview(name, 40)}" · broadcast to web + device`)
}
return
}
case 'agent_create_status': {
const creationId = payload.creationId
if (!validCreationId(creationId)) { reply(type, requestId, { error: 'INVALID_CREATION_ID' }); return }
try {
reply(type, requestId, { creationId, ...await this.creationStatusPayload(this.agentCreations.status(creationId)) })
} catch (error) {
reply(type, requestId, { error: error instanceof AgentCreationReceiptError ? error.code : 'INTERNAL' })
}
return
}
case 'agent_create': {
const engine = payload.engine as AgentEngine | undefined
const cwd = payload.cwd
if (typeof engine !== 'string' || !ENGINES.includes(engine)) { reply(type, requestId, { error: 'INVALID_ENGINE' }); return }
let projectFolder
try { projectFolder = parseProjectFolder(payload) }
catch (error) {
reply(type, requestId, { error: error instanceof ProjectFolderError ? error.code : 'INVALID_PROJECT_SOURCE' }); return
}
// A terminal opens where a terminal app would — the home directory — when the client names
// no folder; every other engine works IN a folder and must be told which.
const terminal = isTerminalEngine(engine)
if (terminal && projectFolder) { reply(type, requestId, { error: 'INVALID_PROJECT_SOURCE', detail: 'a terminal opens in a folder, it does not prepare one' }); return }
if (!projectFolder && !(terminal && cwd === undefined) && (typeof cwd !== 'string' || !isAbsolute(cwd))) { reply(type, requestId, { error: 'INVALID_CWD' }); return }
if (!this.onCreateAgent) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const creationId = payload.creationId
if (creationId !== undefined && !validCreationId(creationId)) {
reply(type, requestId, { error: 'INVALID_CREATION_ID' }); return
}
if (projectFolder && (!validCreationId(creationId) || cwd !== undefined)) {
reply(type, requestId, { error: 'INVALID_PROJECT_SOURCE' }); return
}
// Absent is the ordinary case and stays indistinguishable from a client that predates grids;
// present-but-malformed is refused here rather than half-applied at launch, because an agent
// that quietly ran on the engine's own login would look like it worked.
const grid = parseGridLaunchOverride(payload.grid)
if (grid.state === 'invalid') { reply(type, requestId, { error: 'INVALID_GRID', detail: grid.reason }); return }
if (terminal && grid.state === 'ok') { reply(type, requestId, { error: 'INVALID_GRID', detail: 'a terminal has no engine to point at a grid' }); return }
// Same validation the desktop app already applies client-side (`Agent._safeCodexHome`) —
// repeated here because a client's own check is not a guarantee about what actually
// arrives on the wire.
const rawCodexHome = typeof payload.codexHome === 'string' ? payload.codexHome : null
const codexHome = rawCodexHome && rawCodexHome.startsWith('/') && rawCodexHome.length <= 4096
&& !/[\x00-\x1f\x7f]/.test(rawCodexHome)
? rawCodexHome
: null
if (codexHome && (engine !== 'codex' || grid.state === 'ok')) {
reply(type, requestId, { error: 'INVALID_CODEX_HOME', detail: 'codexHome is only valid for codex, without a grid' })
return
}
// A DSH is refused, never approximated: an agent created as its plain base engine would look
// like it worked and have none of the skills the user picked the tile for.
let dsh: string | null = null
if (payload.dsh !== undefined && payload.dsh !== null) {
if (typeof payload.dsh !== 'string' || !DSH_ID_RE.test(payload.dsh)) {
reply(type, requestId, { error: 'INVALID_DSH', detail: 'dsh must be an owner/name id' }); return
}
const installed = installedDsh(payload.dsh)
if (!installed) {
reply(type, requestId, { error: 'INVALID_DSH', detail: `${payload.dsh} is not installed on this machine` }); return
}
if (installed.manifest.kind === 'viewer') {
reply(type, requestId, { error: 'INVALID_DSH', detail: `${payload.dsh} is a viewer package, not an agent` }); return
}
if (installed.manifest.engine !== engine) {
reply(type, requestId, { error: 'INVALID_DSH', detail: `${payload.dsh} runs on ${installed.manifest.engine}, not ${engine}` }); return
}
dsh = installed.id
}
// A first prompt is refused BEFORE any pane exists: an engine with no way to take one would
// otherwise open on an empty input and look like the person's request had been heard. The
// length bound is a first message's, not a document's. The text itself is never logged.
let prompt: string | null = null
if (payload.prompt !== undefined && payload.prompt !== null) {
if (typeof payload.prompt !== 'string') { reply(type, requestId, { error: 'INVALID_PROMPT', detail: 'prompt must be a string' }); return }
const trimmed = payload.prompt.trim()
if (trimmed.length > MAX_FIRST_PROMPT_CHARS) {
reply(type, requestId, { error: 'PROMPT_TOO_LONG', detail: `prompt is longer than ${MAX_FIRST_PROMPT_CHARS} characters` }); return
}
if (trimmed && !supportsFirstPrompt(engine)) {
reply(type, requestId, { error: 'PROMPT_UNSUPPORTED', detail: new FirstPromptUnsupportedError(engine).message }); return
}
prompt = trimmed || null
}
// Blank is "number it", the same as absent — a client that sends an empty field is not
// asking for an agent with no name.
const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : null
// The engine's named agent is refused BEFORE any pane exists, like the prompt: an engine with
// no way to open as one would otherwise come up as a general session under that agent's
// name. The shape is an identifier the engine looks a file up by — never a path.
let agent: string | null = null
if (payload.agent !== undefined && payload.agent !== null) {
if (typeof payload.agent !== 'string' || !AGENT_NAME_RE.test(payload.agent)) {
reply(type, requestId, { error: 'INVALID_AGENT', detail: 'agent must be 1-64 letters, digits, `-` or `_`' }); return
}
if (!supportsNamedAgent(engine)) {
reply(type, requestId, { error: 'AGENT_UNSUPPORTED', detail: new NamedAgentUnsupportedError(engine).message }); return
}
agent = payload.agent
}
// A permission mode picked in New Harness. A client that predates the choice sends only
// `bypassPermission`; one that sends a mode this engine does not have is refused rather than
// quietly launched in some other mode.
let permissionMode: string | null = null
if (payload.permissionMode !== undefined && payload.permissionMode !== null) {
if (typeof payload.permissionMode !== 'string' || !permissionModeFlags(engine, payload.permissionMode)) {
reply(type, requestId, { error: 'INVALID_PERMISSION_MODE', detail: `${engine} has no permission mode ${JSON.stringify(payload.permissionMode)}` }); return
}
permissionMode = payload.permissionMode
}
const input = {
engine,
cwd: typeof cwd === 'string' ? cwd : terminal ? homedir() : '',
// On unless a client says otherwise: a harness works without stopping to ask for each command.
bypassPermission: permissionMode ? permissionModeApproves(permissionMode) : payload.bypassPermission !== false,
permissionMode,
grid: grid.state === 'ok' ? grid.override : null,
codexHome,
dsh,
prompt,
name,
agent,
}
if (creationId !== undefined) {
// Reserve before spawning. A transport retry carries the SAME creationId; a deliberate
// New agent action carries a new one. Detach so a status check can pass a slow create
// on this connection, just as engines_probe is detached above.
const create = this.onCreateAgent
try {
void this.agentCreations.run(creationId, creationFingerprint(projectFolder ? { ...input, projectFolder } : input), async () => {
let preparedFolder: string | undefined
if (projectFolder) {
try { preparedFolder = await prepareProjectFolder(projectFolder, { label: (dsh ? installedDsh(dsh)?.manifest.name : null) ?? engineLabel(input.engine) }) }
catch (error) {
return { state: 'failed', error: error instanceof ProjectFolderError ? error.code : 'PROJECT_PREPARATION_FAILED',
detail: error instanceof ProjectFolderError ? error.message : 'Could not prepare the project folder.' }
}
// A folder this daemon just made is one Claude Code need not ask about.
try {
if (input.engine === 'claude') preTrustClaudeProject(preparedFolder)
if (input.engine === 'codex') preTrustCodexProject(preparedFolder)
} catch (error) { console.warn(`[agent] pre-trust ${preparedFolder} · ${error instanceof Error ? error.message : error}`) }
}
const result = await create(preparedFolder ? { ...input, cwd: preparedFolder } : input)
if (result.ok) return { state: 'created', agentId: result.session.agentId }
// tmux may have executed before a timeout; registration cleanup is best-effort.
// Neither can prove that no process started, so never encourage another launch.
if (result.error === 'SPAWN_FAILED' || result.error === 'REGISTRATION_FAILED') return { state: 'unconfirmed' }
return { state: 'failed', error: result.error, ...(preparedFolder ? { preparedFolder } : {}), ...(result.detail ? { detail: result.detail.slice(0, 2000) } : {}) }
}).then(async (status) => {
reply(type, requestId, { creationId, ...await this.creationStatusPayload(status) })
}).catch(() => reply(type, requestId, { error: 'INTERNAL' }))
} catch (error) {
reply(type, requestId, { error: error instanceof AgentCreationReceiptError ? error.code : 'INTERNAL' })
}
return
}
// Clients predating receipts retain their existing response shape.
const result = await this.onCreateAgent(input)
// `detail` carries the underlying cause (tmux's own message) so the person who clicked
// Create can read it, rather than having to open a log on the machine that failed.
if (!result.ok) {
reply(type, requestId, result.detail ? { error: result.error, detail: result.detail } : { error: result.error })
return
}
reply(type, requestId, { agent: await this.toProject(result.session) })
return
}
// Move a RUNNING agent onto a grid. The pane survives; its process is re-exec'd with the
// engine's grid environment and, when one is bound, `--resume <session>` so the conversation
// comes back. Refused rather than half-applied: see cli.ts for what it checks first.
case 'agent_retarget': {
const agentId = (payload.agentId as string | undefined) || (payload.sessionId as string | undefined)
if (!agentId) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
if (!this.onRetargetAgent) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const clear = payload.clearGrid === true
// `gridModel` is the header picker's frame: a model id and nothing else. The endpoint and
// the credential are resolved HERE, from this machine's own signed-in `grid`, so neither
// ever crosses the relay and the app cannot be the source of truth for an address it does
// not know. A client that sends the full `grid` object still works unchanged.
const picked = typeof payload.gridModel === 'string' ? payload.gridModel : ''
if (picked && payload.grid === undefined && !clear) {
// The grid the model was picked FROM, when the picker says (a shared grid's section);
// the account's own grid otherwise, as before.
const pickedGrid = typeof payload.gridName === 'string' && payload.gridName.trim()
? payload.gridName.trim()
: await this.resolveGridName()
const resolved = await resolveGridTarget(pickedGrid, picked)
if (!resolved) {
reply(type, requestId, { error: 'GRID_UNAVAILABLE', detail: 'Could not read this machine\'s grid endpoint.' })
return
}
payload.grid = resolved
}
const target = parseGridLaunchOverride(payload.grid)
// Exactly one, and `clearGrid` is a separate field rather than `grid: null` on purpose:
// parseGridLaunchOverride already answers `absent` for both undefined and null, so
// overloading null would make "I forgot the field" and "I mean own login" the same frame.
if (clear && target.state !== 'absent') {
reply(type, requestId, { error: 'INVALID_GRID', detail: 'clearGrid and grid are mutually exclusive' })
return
}
if (!clear && target.state !== 'ok') {
reply(type, requestId, {
error: 'INVALID_GRID',
detail: target.state === 'invalid' ? target.reason : 'grid is required',
})
return
}
const moved = await this.onRetargetAgent({ agentId, grid: target.state === 'ok' ? target.override : null })
if (!moved.ok) {
reply(type, requestId, moved.detail ? { error: moved.error, detail: moved.detail } : { error: moved.error })
return
}
reply(type, requestId, { retargeted: true })
return
}
// Delete an agent: signal its validated engine process and drop it from the list. Idempotent — an already
// gone target still acks + re-emits agent_deleted so the web/device converge. E2EE-gated (the
// frame arrived decrypted). Keeps the persisted recap + agent name for a later resume.
case 'agent_delete': {
const target = (payload.agentId as string | undefined) || (payload.sessionId as string | undefined)
if (!target) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
this.onDeleteAgent?.(target)
reply(type, requestId, { deleted: true })
return
}
// Restart an agent: exit its live engine process and relaunch it in the SAME tmux pane, keeping
// the SAME agentId. Follows agent_create/agent_delete's exact validate → delegate → reply shape.
case 'agent_restart': {
const target = payload.agentId as string | undefined
if (!target) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
if (!this.onRestartAgent) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const result = await this.onRestartAgent(target)
if (!result.ok) {
reply(type, requestId, result.detail ? { error: result.error, detail: result.detail } : { error: result.error })
return
}
reply(type, requestId, { agent: await this.toProject(result.session), resumed: result.resumed })
return
}
// Fork an agent: a second one with the first one's history — see lib/forkAgent.ts. The reply
// is agent_create's shape plus `level`, so a client opens the pane the same way.
case 'agent_fork': {
const target = payload.agentId as string | undefined
if (!target) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
if (!this.onForkAgent) { reply(type, requestId, { error: 'UNSUPPORTED_ON_REMOTE' }); return }
const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim().slice(0, 120) : null
const rawPrompt = payload.prompt
if (rawPrompt !== undefined && rawPrompt !== null && typeof rawPrompt !== 'string') { reply(type, requestId, { error: 'INVALID_PROMPT' }); return }
const prompt = typeof rawPrompt === 'string' && rawPrompt.trim() ? rawPrompt : null
if (prompt && prompt.length > MAX_FIRST_PROMPT_CHARS) { reply(type, requestId, { error: 'PROMPT_TOO_LONG' }); return }
const result = await this.onForkAgent({ agentId: target, name, prompt })
if (!result.ok) {
reply(type, requestId, result.detail ? { error: result.error, detail: result.detail } : { error: result.error })
return
}
reply(type, requestId, { agent: await this.toProject(result.session), level: result.level })
return
}
case 'agent_files': {
// SourceTree list — rooted at the tmux session's working dir.
const projectId = payload.agentId as string | undefined
if (!projectId) { reply(type, requestId, { error: 'MISSING_AGENT_ID' }); return }
const s = registry.resolve(projectId)
if (!s?.cwd) { reply(type, requestId, { error: 'AGENT_NOT_FOUND' }); return }
try { reply(type, requestId, { files: listFileTree(s.cwd) }) }
catch (e) { reply(type, requestId, { error: e instanceof Error ? e.message : 'FILE_TREE_ERROR' }) }
return
}
case 'project_preview': {
const path = typeof payload.path === 'string' ? payload.path : ''
// Preview work is detached so typing and other RPCs stay responsive.
void projectPreview(path, registry.list().flatMap(agent => agent.cwd ? [agent.cwd] : []))
.then(result => reply(type, requestId, result))
.catch(() => reply(type, requestId, { error: 'UNAVAILABLE' }))
return
}
case 'fs_list_dir': {
// One-level remote directory listing for the New Agent folder browser.
const path = typeof payload.path === 'string' ? payload.path : ''
const result = listDir(path)
if ('error' in result) { reply(type, requestId, { error: result.error }); return }
reply(type, requestId, { ...result })
return
}
case 'codex_profiles_list': {
// Which CODEX_HOME folders THIS machine can offer — answered here, on the machine in
// question, for the same reason `engines_probe` is: a Codex profile is a folder on disk,
// and a folder on a Mac means nothing on the Docker rig it was asked about instead.
const observed = Array.isArray(payload.observedPaths)
? payload.observedPaths.filter((p): p is string => typeof p === 'string')
: []
try {
reply(type, requestId, { profiles: listCodexProfiles(observed) })
} catch {
reply(type, requestId, { error: 'CODEX_PROFILES_FAILED' })
}
return
}
case 'codex_profile_link': {
const path = typeof payload.path === 'string' ? payload.path : ''
const result = linkCodexProfile(path)
if ('error' in result) { reply(type, requestId, { error: result.error }); return }
reply(type, requestId, { profile: result })
return
}
case 'agent_read_file': {
// Text reads keep their ≤5 MB guard; media uses bounded binary chunks.
const projectId = payload.agentId as string | undefined
const path = payload.path as string | undefined
if (!projectId || !path) { reply(type, requestId, { error: 'MISSING_AGENT_OR_PATH' }); return }
const s = registry.resolve(projectId)
if (!s?.cwd) { reply(type, requestId, { error: 'AGENT_NOT_FOUND' }); return }
if (payload.media === true) {
if (typeof path !== 'string') { reply(type, requestId, { error: 'MEDIA_INVALID_REQUEST' }); return }
try {
reply(type, requestId, { ...await readMediaPreviewChunk(s.cwd, path, payload.offset, payload.revision) })
} catch (error) {
reply(type, requestId, { error: error instanceof MediaPreviewError ? error.message : 'MEDIA_READ_FAILED' })
}
return
}
try { reply(type, requestId, { path, content: readProjectFile(s.cwd, path) }) }
catch (e) { reply(type, requestId, { error: e instanceof Error ? e.message : 'NOT_FOUND' }) }
return
}
case 'claude_login_status':
// Legacy RPC name; report the selected agent's actual engine when one was supplied.
{
const target = (payload.agentId as string | undefined) || (payload.sessionId as string | undefined)
reply(type, requestId, { loggedIn: true, engine: (target ? registry.resolve(target)?.engine : undefined) ?? 'claude', account: hostname() })
}
return
case 'message': {
const content = payload.content as string | undefined
const target = (payload.agentId as string | undefined) || (payload.sessionId as string | undefined)
if (!content || !target) return
// Inject into the pane; the resulting JSONL user/assistant lines drive the turn lifecycle back
// to the web (mirror-all) — no synthetic events here. Prefer cli.ts's handler (inject + Enter
// retry); fall back to a direct inject when unwired (isolation/tests).
if (this.onMessage) this.onMessage(target, content)
else console.warn('[backend] message handler is not wired; terminal input was not dispatched')
return
}
case 'cancel': {
const target = (payload.agentId as string | undefined) || (payload.sessionId as string | undefined)
if (target) this.onCancel?.(target)
return
}
case 'speaking': {
// A device is capturing a voice instruction. Mirror the hosted runtime: re-broadcast the presence
// signal to the web (indicator) + other devices. Ephemeral, no requestId.
const p = payload as { speaking?: boolean; agentId?: string; sessionId?: string }
const sessionId = p.sessionId ?? p.agentId
const projectId = p.agentId ?? p.sessionId ?? null
const on = p.speaking !== false
// Echo the canonical agent id, whichever id the sender used to address it.
const speaker = projectId ? registry.resolve(projectId) : undefined
const speakerAgentId = speaker?.agentId ?? projectId
const evt = { type: 'speaking', dbSessionId: speaker?.sessionId ?? sessionId, agentId: speakerAgentId, payload: { speaking: on, agentId: speakerAgentId, sessionId: speaker?.sessionId ?? sessionId } }
this.send(evt) // web
this.sendCommander(evt) // other devices (firmware ignores its own echo; matches node forwardToCommander)
return
}
case 'question_response': {
// A device answered an AskUserQuestion. There's no control channel into an interactive CLI, so
// cli.ts keys the answer straight into that session's tmux dialog.
const p = payload as { requestId?: string; sessionId?: string; agentId?: string; answers?: Record<string, string> }
this.onQuestionAnswer?.(p)
return
}
// This machine's Claude/Codex rate limits, read with ITS OWN credentials. The desktop reads the
// account on the computer it runs on directly; this is how it reads one on a machine it does
// not — which may be signed in to a different subscription entirely. The vendor's answer goes
// back as it came: see lib/accountUsage.ts for why the parsing stays on the client.
case 'usage_read': {
// Two vendor round trips (up to 8s each, lib/accountUsage.ts) — detached from the ordered
// chain for the same reason as `grid_models_list`: it is asked on connect beside the RPCs
// the terminal needs, and with no network it held them past the app's timeout.
void this.accountUsageReader()
.then((providers) => reply(type, requestId, { providers }))
.catch(() => reply(type, requestId, { error: 'USAGE_READ_FAILED' }))
return
}
// The colours the desktop paints its panes with, so tmux answers a TUI's OSC 10/11 with
// them instead of with whatever terminal happened to attach first (lib/hostTheme.ts).
case 'theme_set': {
if (!this.hostThemeSink) { reply(type, requestId, { error: 'UNSUPPORTED' }); return }
const theme = parseHostTheme(payload)
if (!theme) { reply(type, requestId, { error: 'BAD_THEME' }); return }
this.hostThemeSink(theme)
reply(type, requestId, { applied: true })
return
}
// v1 no-ops: no programmatic session control over an interactive tmux claude.
case 'new_chat':
case 'compact':
case 'permission_response':
return
default:
// Unknown RPC with a requestId: reject fast so the web promise doesn't wait out its 20s.
if (requestId !== undefined) reply(type, requestId, { error: 'UNSUPPORTED' })
return
}
} catch (err) {
console.error(`[backend] dispatch ${type} failed:`, err)
if (requestId !== undefined) reply(type, requestId, { error: 'INTERNAL' })
}
}
/** Recover by stable runtime identity; a deleted agent must never become a fresh launch. */
private async creationStatusPayload(status: AgentCreationStatus): Promise<Record<string, unknown>> {
if (status.state === 'created') {
const session = registry.byAgent(status.agentId)
return session
? { state: 'created', agent: await this.toProject(session) }
: { state: 'unavailable' }
}
// A recorded refusal is a completed outcome. Keep it separate from transport/dispatch errors
// so clients can distinguish "safe to correct the choices" from "outcome still unknown".
if (status.state === 'failed') {
return { state: 'failed', ...(status.preparedFolder ? { preparedFolder: status.preparedFolder } : {}), failure: { code: status.error, ...(status.detail ? { detail: status.detail } : {}) } }
}
return status
}
/** Map a registered tmux session onto the web's Project shape (tabs in ProjectTabs). */
private toProject(s: RegisteredSession): Promise<AgentFrame> {
return agentFrame(s, {
selectedModel: this.runtimeProfileProvider?.(s) ?? null,
terminalAvailable: registry.terminalAvailable(s.agentId),
dsh: this.dshFrameProvider?.(s) ?? null,
})
}
}