mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* 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)
309 lines
8.8 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|