Files
orca/src/shared/remote-runtime-request-connection.ts
T
Brennan Benson cd8c66551a fix(agent-hooks): resumed Claude Code session gets its sidebar agent row at SessionStart (STA-3386) (#12859)
* fix(agent-hooks): give resumed Claude sessions a sidebar row at SessionStart (STA-3386)

Claude's hook set never registered SessionStart and normalizeClaudeEvent
dropped it at ingest, so a resumed session that idled produced zero hook
traffic and earned no sidebar agent row until the first prompt.

- Register SessionStart in CLAUDE_EVENTS (local + remote installs).
- Map lead SessionStart (startup/resume/clear) to an idle 'done' row,
  resetting stale roster/task/cron/tool/prompt state like the Codex path;
  compact restarts and child-attributed SessionStart stay dropped.
- Thread hookEventName through the agent-status IPC payload so the
  completion coordinator can tell a session connect from a turn result;
  a SessionStart 'done' no longer raises agent-task-complete.

* fix(agent-hooks): mark SessionStart rows as session boundaries, not completions (STA-3386)

Review follow-up: represent the idle connect as a first-class
sessionBoundary flag on the status payload instead of gating one
renderer consumer on hookEventName.

- sessionBoundary rides AgentStatusPayload/AgentStatusEntry (done-only,
  clamped like interrupted); drops the hookEventName IPC threading.
- Completion-reactive consumers ignore session boundaries: the
  completion coordinator (task-complete notifications), automation
  dispatch observers (a connecting agent no longer completes the run
  and closes its tab), activity unread counts, and the dashboard
  finished timestamp; the status slice keeps boundaries out of
  stateHistory and preserves the flag across done->done repaints.
- SessionStart sources are allowlisted (startup/resume/clear) so
  compact restarts or unknown sources fail closed mid-turn.
- A live SessionStart now un-retires a reusable pane like a fresh
  prompt, so resume-in-reused-pane earns its row too.

* fix(agent-hooks): keep session-boundary dones out of teardown and completion history (STA-3386)

Review round 2:
- A boundary done no longer deletes the pane's launch-config registry
  entry, so a resumed idle TUI keeps its registered-launch-agent
  identity evidence.
- A boundary landing on a REAL done pushes that completion into
  stateHistory so the finished timestamp and unread badge survive a
  resume//clear right after a finish.
- The done->done flag carry yields to turn evidence (assistant message
  or changed prompt) so a genuine completion can never be suppressed.
- Star-nag value-moment observer and the server's OSC-equivalence
  dedupe now discriminate the flag.

* fix(agent-hooks): keep a displaced completion unread in the sidebar badge (STA-3386)

Review round 3: sidebar-badge mode counts only the live entry, so a
session boundary landing on an unacknowledged completion silently
dropped the sidebar badge while the agent-events count kept it. Count
the displaced completion from history for boundary rows, and pin the
behavior with countActivityUnread tests.

* fix(agent-hooks): prevent SessionStart completion side effects (STA-3386)

* fix(agent-hooks): preserve SessionStart through renderer IPC (STA-3386)
2026-08-05 22:06:36 -07:00

309 lines
8.8 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import type { PairingOffer } from './pairing'
import { decrypt, encrypt } from './e2ee-crypto'
import type { RuntimeRpcResponse } from './runtime-rpc-envelope'
import {
serializeRemoteRuntimePayload,
serializeRemoteRuntimeRpcRequest
} from './remote-runtime-memory-limits'
import {
prepareRemoteRuntimeRequest,
releaseRemoteRuntimePreparedRequest,
takeRemoteRuntimePreparedRequest,
toRemoteRuntimeRequestError,
type RemoteRuntimePendingRequest,
type RemoteRuntimePreparedRequest
} from './remote-runtime-prepared-request-admission'
import {
invalidRemoteRuntimeResponseError,
parseAuthenticatedFrame,
parseReadyFrame,
parseRemoteRuntimeRpcFrame,
remoteRuntimeTimeoutError,
remoteRuntimeUnavailableError
} from './remote-runtime-request-frames'
import {
rejectRemoteRuntimeRequestReadyWaiters,
resolveRemoteRuntimeRequestReadyWaiters,
waitForRemoteRuntimeRequestReady,
type RemoteRuntimeRequestReadyWaiter
} from './remote-runtime-request-ready-waiters'
import { openRemoteRuntimeWebSocket } from './remote-runtime-request-websocket'
import {
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY
} from './protocol-version'
type ConnectionState = 'closed' | 'awaiting_ready' | 'awaiting_authenticated' | 'ready'
const IDLE_CLOSE_MS = 60_000
export class RemoteRuntimeRequestConnection {
private state: ConnectionState = 'closed'
private ws: WebSocket | null = null
private sharedKey: Uint8Array | null = null
private socketCleanup: (() => void) | null = null
private readonly pendingRequests = new Map<string, RemoteRuntimePendingRequest<unknown>>()
private readonly readyWaiters: RemoteRuntimeRequestReadyWaiter[] = []
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null
constructor(private readonly pairing: PairingOffer) {}
request<TResult>(
method: string,
params: unknown,
timeoutMs: number
): Promise<RuntimeRpcResponse<TResult>> {
const requestId = randomUUID()
let preparedRequest: RemoteRuntimePreparedRequest
try {
preparedRequest = prepareRemoteRuntimeRequest(this.pendingRequests, () =>
serializeRemoteRuntimeRpcRequest({
requestId,
deviceToken: this.pairing.deviceToken,
method,
params
})
)
} catch (error) {
return Promise.reject(toRemoteRuntimeRequestError(error))
}
this.clearIdleCloseTimer()
return new Promise<RuntimeRpcResponse<TResult>>((resolve, reject) => {
const timeout = setTimeout(() => {
const pending = this.pendingRequests.get(requestId)
if (!pending) {
return
}
this.pendingRequests.delete(requestId)
releaseRemoteRuntimePreparedRequest(pending)
const error = remoteRuntimeTimeoutError()
pending.reject(error)
this.close(error)
}, timeoutMs)
this.pendingRequests.set(requestId, {
resolve: resolve as (response: RuntimeRpcResponse<unknown>) => void,
reject,
timeout,
preparedRequest
})
void this.ensureReady().then(
() => this.sendRequest(requestId),
(error) => this.rejectPendingRequest(requestId, toRemoteRuntimeRequestError(error))
)
})
}
close(error?: Error): void {
const ws = this.ws
const cleanup = this.socketCleanup
this.ws = this.sharedKey = null
this.socketCleanup = null
this.state = 'closed'
this.clearIdleCloseTimer()
const closeError = error ?? remoteRuntimeUnavailableError()
rejectRemoteRuntimeRequestReadyWaiters(this.readyWaiters, closeError)
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout)
this.pendingRequests.delete(requestId)
releaseRemoteRuntimePreparedRequest(pending)
pending.reject(closeError)
}
try {
cleanup?.()
ws?.close()
} catch {
// Best-effort shutdown for a cached remote control connection.
}
}
private ensureReady(): Promise<void> {
const ws = this.ws
if (this.state === 'ready' && ws?.readyState === WebSocket.OPEN && this.sharedKey) {
return Promise.resolve()
}
const promise = waitForRemoteRuntimeRequestReady(this.readyWaiters)
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
try {
this.open()
} catch (error) {
this.close(toRemoteRuntimeRequestError(error))
}
}
return promise
}
private open(): void {
const opened = openRemoteRuntimeWebSocket(this.pairing, {
onClose: (ws) => {
if (this.ws === ws) {
this.close()
}
},
onError: (ws, error) => {
if (this.ws === ws) {
this.close(error)
}
},
onTextFrame: (ws, frame) => {
if (this.ws === ws) {
this.handleTextFrame(frame)
}
}
})
if (!opened.ok) {
this.close(opened.error)
return
}
this.ws = opened.socket.ws
this.sharedKey = opened.socket.sharedKey
this.socketCleanup = opened.socket.cleanup
this.state = 'awaiting_ready'
}
private handleTextFrame(frame: string): void {
if (this.state === 'awaiting_ready') {
this.handleReadyFrame(frame)
return
}
const sharedKey = this.sharedKey
if (!sharedKey) {
return
}
const plaintext = decrypt(frame, sharedKey)
if (plaintext === null) {
this.close(
invalidRemoteRuntimeResponseError('Remote Orca runtime returned an undecryptable frame.')
)
return
}
if (this.state === 'awaiting_authenticated') {
this.handleAuthenticatedFrame(plaintext)
return
}
this.handleRpcFrame(plaintext)
}
private handleReadyFrame(frame: string): void {
const error = parseReadyFrame(frame)
if (error) {
this.close(error)
return
}
this.state = 'awaiting_authenticated'
const sharedKey = this.sharedKey
if (!sharedKey) {
return
}
this.ws?.send(
encrypt(
serializeRemoteRuntimePayload({
type: 'e2ee_auth',
deviceToken: this.pairing.deviceToken,
clientCapabilities: [
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY
]
}),
sharedKey
)
)
}
private handleAuthenticatedFrame(plaintext: string): void {
const error = parseAuthenticatedFrame(plaintext)
if (error) {
this.close(error)
return
}
this.state = 'ready'
resolveRemoteRuntimeRequestReadyWaiters(this.readyWaiters)
this.scheduleIdleCloseIfUnused()
}
private handleRpcFrame(plaintext: string): void {
const parsed = parseRemoteRuntimeRpcFrame(plaintext)
if (parsed.type === 'keepalive') {
return
}
if (parsed.type === 'error') {
this.close(parsed.error)
return
}
const response = parsed.response
const pending = this.pendingRequests.get(response.id)
if (!pending) {
return
}
this.pendingRequests.delete(response.id)
clearTimeout(pending.timeout)
releaseRemoteRuntimePreparedRequest(pending)
pending.resolve(response)
this.scheduleIdleCloseIfUnused()
}
private sendRequest(requestId: string): void {
const pending = this.pendingRequests.get(requestId)
const ws = this.ws
const sharedKey = this.sharedKey
if (!pending) {
return
}
if (this.state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN || !sharedKey) {
this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError())
return
}
const serializedRequest = takeRemoteRuntimePreparedRequest(pending)
if (serializedRequest === null) {
this.rejectPendingRequest(requestId, remoteRuntimeUnavailableError())
return
}
try {
ws.send(encrypt(serializedRequest, sharedKey))
} catch (error) {
this.rejectPendingRequest(requestId, toRemoteRuntimeRequestError(error))
}
}
private rejectPendingRequest(requestId: string, error: Error): void {
const pending = this.pendingRequests.get(requestId)
if (!pending) {
return
}
this.pendingRequests.delete(requestId)
clearTimeout(pending.timeout)
releaseRemoteRuntimePreparedRequest(pending)
pending.reject(error)
this.scheduleIdleCloseIfUnused()
}
private scheduleIdleCloseIfUnused(): void {
if (this.pendingRequests.size > 0 || this.readyWaiters.length > 0 || this.state !== 'ready') {
return
}
this.clearIdleCloseTimer()
this.idleCloseTimer = setTimeout(() => this.close(), IDLE_CLOSE_MS)
if (typeof this.idleCloseTimer.unref === 'function') {
this.idleCloseTimer.unref()
}
}
private clearIdleCloseTimer(): void {
if (this.idleCloseTimer) {
clearTimeout(this.idleCloseTimer)
this.idleCloseTimer = null
}
}
}