import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { isAbsolute } from 'node:path'; import { adoptRegisteredWorkspaceSession } from './harness-session-binding.mjs'; // Every channel plugin runs in the same Host process. Sharing ownership by // Harness origin prevents two channel-specific clients bound to one Session // from claiming or cancelling each other's interactions. const interactionRegistries = new Map(); function interactionRegistry(origin) { let registry = interactionRegistries.get(origin); if (!registry) { registry = { ownerships: new Map(), claims: new Map(), nextOrder: 0 }; interactionRegistries.set(origin, registry); } return registry; } function workspacePaths(value) { if (!Array.isArray(value?.items)) return []; return value.items.flatMap((item) => ( typeof item?.path === 'string' && isAbsolute(item.path) ? [item.path] : [] )); } function workspaceFromList(workspacePath, workspaceList) { if (!Array.isArray(workspaceList?.items) || !Array.isArray(workspaceList?.archivedSessionIds)) { throw new Error('Harness returned an invalid response for workspace.list'); } const workspace = workspaceList.items.find((item) => item?.path === workspacePath); if (!workspace) return null; if (!Array.isArray(workspace.sessionIds) || workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) { throw new Error('Harness returned invalid session IDs for workspace.list'); } return workspace; } function toEpochMs(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value < 1e12 ? value * 1000 : value; } if (typeof value === 'string' && value) { const parsed = Date.parse(value); if (!Number.isNaN(parsed)) return parsed; } return null; } function sessionTimeMs(summary) { if (!summary || typeof summary !== 'object') return null; const candidates = [ summary?.header?.lastActivityAt, summary?.header?.updatedAt, summary?.header?.createdAt, summary?.projections?.values?.lastActivityAt, summary?.projections?.values?.updatedAt, summary?.projections?.values?.createdAt, summary?.lastActivityAt, summary?.updatedAt, summary?.createdAt, ]; for (const value of candidates) { const ms = toEpochMs(value); if (ms !== null) return ms; } return null; } function workspaceSessions(workspace, archivedSessionIds, sessionList) { if (!Array.isArray(sessionList?.items)) { throw new Error('Harness returned an invalid response for session.list'); } const archived = new Set(archivedSessionIds); const summaries = new Map(sessionList.items.flatMap((item) => ( typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : [] ))); return { workspace: workspace.path, sessions: workspace.sessionIds.map((sessionId) => { const summary = summaries.get(sessionId); const title = summary?.projections?.values?.title; const session = { sessionId, title: typeof title === 'string' ? title : null, archived: archived.has(sessionId), blank: summary?.blank === true, origin: summary?.origin === 'subagent' ? 'subagent' : null, summaryAvailable: summary !== undefined, }; const time = sessionTimeMs(summary); if (time !== null) session.time = time; return session; }), }; } function sleep(ms, signal) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); return; } const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); const onAbort = () => { clearTimeout(timer); reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); }; signal?.addEventListener('abort', onAbort, { once: true }); }); } function assistantMessageText(event) { return (event?.data?.message?.content ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text) .join('\n') .trim(); } function consumeInteractionOwnership(ownership, entries) { const ordered = [...entries] .map((entry) => entry?.event ?? entry) .filter(Boolean) .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); for (const event of ordered) { const seq = event.seq ?? -1; if (seq <= ownership.lastSeq) continue; ownership.lastSeq = seq; if (event.type === 'turn/start') { const turn = event.data?.turn ?? null; if (ownership.active && turn !== ownership.turn) ownership.active = false; if (ownership.turn !== null && turn !== ownership.turn) ownership.completed = true; ownership.openTurn = turn; continue; } if (event.type === 'user/message' && event.data?.source?.rpcId === ownership.promptRpcId) { ownership.active = true; ownership.started = true; ownership.completed = false; ownership.turn = event.data?.turn ?? ownership.openTurn; continue; } if (event.type === 'turn/end' && event.data?.turn === ownership.turn) { ownership.active = false; ownership.completed = true; continue; } let toolCall = null; if (event.type === 'tool/call' && ownership.active && event.data?.turn === ownership.turn && typeof event.data?.callId === 'string' && event.data.callId) { toolCall = { callId: event.data.callId, name: event.data?.name, arguments: event.data?.arguments, }; } else if (event.type === 'tool/code-dispatch-start' && ownership.active && typeof event.data?.subCallId === 'string' && event.data.subCallId) { let argumentsText; try { argumentsText = JSON.stringify(event.data?.arguments); } catch { argumentsText = undefined; } toolCall = { callId: event.data.subCallId, name: event.data?.name, arguments: argumentsText, }; } if (toolCall) ownership.toolCalls.set(toolCall.callId, Object.freeze(toolCall)); } } export class HarnessReplyTracker { #promptRpcId; #lastSeq; #openTurn = null; #targetTurn = null; #stepText = new Map(); #latestText = ''; #finished = false; #reason = null; constructor({ promptRpcId, afterSeq = -1 }) { this.#promptRpcId = promptRpcId; this.#lastSeq = afterSeq; } get finished() { return this.#finished; } get answer() { return this.#latestText.trim(); } get reason() { return this.#reason; } get tracking() { return this.#targetTurn !== null && !this.#finished; } get turn() { return this.#targetTurn; } consume(entries) { let update = null; const ordered = [...entries] .map((entry) => entry?.event ?? entry) .filter(Boolean) .sort((left, right) => (left.seq ?? -1) - (right.seq ?? -1)); for (const event of ordered) { const seq = event.seq ?? -1; if (seq <= this.#lastSeq) continue; this.#lastSeq = seq; if (event.type === 'turn/start') this.#openTurn = event.data?.turn ?? null; if (event.type === 'user/message' && event.data?.source?.rpcId === this.#promptRpcId) { this.#targetTurn = this.#openTurn; continue; } if (this.#targetTurn === null) continue; if (event.type === 'turn/end') { if (event.data?.turn !== this.#targetTurn) continue; this.#finished = true; this.#reason = event.data?.reason ?? null; this.#openTurn = null; continue; } if (event.data?.turn !== this.#targetTurn) continue; if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'text-delta') { const step = event.data?.step ?? 0; const index = event.data.chunk.index ?? 0; const key = `${step}:${index}`; this.#stepText.set(key, (this.#stepText.get(key) ?? '') + event.data.chunk.text); const prefix = `${step}:`; const text = [...this.#stepText.entries()] .filter(([partKey]) => partKey.startsWith(prefix)) .sort(([left], [right]) => Number(left.split(':')[1]) - Number(right.split(':')[1])) .map(([, part]) => part) .join('\n') .trim(); if (text && text !== this.#latestText) { this.#latestText = text; update = { type: 'text', text }; } continue; } if (event.type === 'assistant/message') { const text = assistantMessageText(event); if (text && text !== this.#latestText) { this.#latestText = text; update = { type: 'text', text }; } continue; } if (event.type === 'tool/call') { update = { type: 'tool', name: event.data?.name ?? '工具' }; } else if (event.type === 'tool/result') { update = { type: 'status', text: '正在整理结果…' }; } } return update; } } export class HarnessRpcError extends Error { constructor(method, error) { super(`${method}: ${error?.message ?? 'unknown Harness RPC error'}`); this.name = 'HarnessRpcError'; this.method = method; this.code = error?.code ?? 'internal'; this.details = error?.details ?? {}; } } export class HarnessInteractionError extends Error { constructor(code, message) { super(message); this.name = 'HarnessInteractionError'; this.code = code; } } export class HarnessClient { #baseUrl; #workspace; #agentPreset; #autostart; #dshBin; #fetch; #createWebSocket; #interactionReconnectDelayMs; #rpcIdPrefix; #logPrefix; #managedProcess = null; #interactionRegistry; #interactionOwnerships; #interactionClaims; constructor({ baseUrl, workspace, agentPreset = 'standard', autostart = false, dshBin = 'dsh', fetchImpl = fetch, createWebSocket = (url) => new WebSocket(url), interactionReconnectDelayMs = 500, rpcIdPrefix = 'im', logPrefix = 'dsh-im', }) { if (typeof createWebSocket !== 'function') { throw new TypeError('createWebSocket must be a function'); } if (!Number.isFinite(interactionReconnectDelayMs) || interactionReconnectDelayMs < 0) { throw new TypeError('interactionReconnectDelayMs must be a non-negative number'); } if (typeof rpcIdPrefix !== 'string' || !rpcIdPrefix.trim()) { throw new TypeError('rpcIdPrefix must be a non-empty string'); } if (typeof logPrefix !== 'string' || !logPrefix.trim()) { throw new TypeError('logPrefix must be a non-empty string'); } this.#baseUrl = new URL(baseUrl); this.#workspace = workspace; this.#agentPreset = agentPreset; this.#autostart = autostart; this.#dshBin = dshBin; this.#fetch = fetchImpl; this.#createWebSocket = createWebSocket; this.#interactionReconnectDelayMs = interactionReconnectDelayMs; this.#rpcIdPrefix = rpcIdPrefix.trim(); this.#logPrefix = logPrefix.trim(); this.#interactionRegistry = interactionRegistry(this.#baseUrl.origin); this.#interactionOwnerships = this.#interactionRegistry.ownerships; this.#interactionClaims = this.#interactionRegistry.claims; } async rpc(method, payload = {}, timeoutMs = 30_000, options = {}) { const rpcId = options.rpcId ?? `${this.#rpcIdPrefix}-${randomUUID()}`; const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; const response = await this.#fetch(new URL(`/api/${method}`, this.#baseUrl), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId, method, payload }), signal, }); if (!response.ok) throw new Error(`Harness transport ${method} failed: HTTP ${response.status}`); const body = await response.json(); if (body?.type !== 'server-response' || body?.rpcId !== rpcId) { throw new Error(`Harness returned an invalid response for ${method}`); } if (!body.result?.ok) throw new HarnessRpcError(method, body.result?.error); return body.result.value; } async health(options = {}) { await this.rpc('host.describe', {}, 5_000, options); return true; } async ensureRunning(options = {}) { try { return await this.health(options); } catch (firstError) { if (!this.#autostart) throw firstError; } if (!this.#managedProcess || this.#managedProcess.exitCode !== null) { const port = this.#baseUrl.port || (this.#baseUrl.protocol === 'https:' ? '443' : '80'); this.#managedProcess = spawn(this.#dshBin, [ 'web', '--host', this.#baseUrl.hostname, '--port', port, ], { cwd: this.#workspace, env: process.env, stdio: ['ignore', 'inherit', 'inherit'], }); this.#managedProcess.on('error', (error) => { console.error(`[${this.#logPrefix}] failed to start Harness:`, error.message); }); } const deadline = Date.now() + 60_000; let lastError; while (Date.now() < deadline) { await sleep(1_000, options.signal); try { return await this.health(options); } catch (error) { lastError = error; } } throw new Error(`Harness did not become ready: ${lastError?.message ?? 'timeout'}`); } async listWorkspaces(options = {}) { await this.ensureRunning(options); return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options)); } async listWorkspaceSessions(workspacePath, options = {}) { await this.ensureRunning(options); const workspaceList = await this.rpc('workspace.list', {}, 30_000, options); const workspace = workspaceFromList(workspacePath, workspaceList); if (!workspace) return { workspace: workspacePath, sessions: [] }; const sessionList = await this.rpc('session.list', {}, 30_000, options); return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList); } async adoptWorkspaceSession(value, options = {}) { return adoptRegisteredWorkspaceSession(this, value, options); } async workspaceId(options = {}) { const { workspace = this.#workspace, ...rpcOptions } = options; const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions); const existing = items.find((item) => item.path === workspace); if (existing) return existing.workspaceId; const created = await this.rpc('workspace.create', { path: workspace }, 30_000, rpcOptions); return created.workspace.workspaceId; } async createSession(options = {}) { await this.ensureRunning(options); const workspaceId = await this.workspaceId(options); const created = await this.rpc('session.create', { workspaceId, agentPreset: this.#agentPreset, }, 30_000, options); return created.sessionId; } async sessionExists(sessionId, options = {}) { try { await this.rpc('session.history', { sessionId, maxMessages: 1 }, 30_000, options); return true; } catch (error) { if (error instanceof HarnessRpcError && error.code === 'session-not-found') return false; throw error; } } async respondInteraction(rpcId, result, options = {}) { if (typeof rpcId !== 'string' || !rpcId) throw new TypeError('rpcId is required'); if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') { throw new TypeError('A Harness RPC result is required'); } const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? 30_000); const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; const response = await this.#fetch(new URL('/api/respond', this.#baseUrl), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response', rpcId, result }), signal, }); if (!response.ok) { throw new Error(`Harness transport respond failed: HTTP ${response.status}`); } const receipt = await response.json(); if (receipt?.accepted === true) return receipt; if (receipt?.accepted !== false || (receipt.reason !== 'bad-response' && receipt.reason !== 'not-pending')) { throw new Error('Harness returned an invalid interaction response receipt'); } const reason = receipt.reason; throw new HarnessInteractionError( `interaction-${reason}`, `Harness interaction response was rejected (${reason})`, ); } async watchInteractions(sessionId, { signal, onInteraction, onResolved, onOpen, ownership, } = {}) { if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required'); if (!signal || typeof signal.addEventListener !== 'function') { throw new TypeError('watchInteractions requires an AbortSignal'); } if (onInteraction !== undefined && typeof onInteraction !== 'function') { throw new TypeError('onInteraction must be a function'); } if (onResolved !== undefined && typeof onResolved !== 'function') { throw new TypeError('onResolved must be a function'); } if (onOpen !== undefined && typeof onOpen !== 'function') { throw new TypeError('onOpen must be a function'); } while (!signal.aborted) { try { await this.#watchInteractionSocket(sessionId, { signal, onInteraction, onResolved, onOpen, ownership, }); } catch (error) { if (signal.aborted) return; console.warn(`[${this.#logPrefix}] Harness interaction stream disconnected:`, error.message); } if (signal.aborted) return; try { await sleep(this.#interactionReconnectDelayMs, signal); } catch { if (signal.aborted) return; throw new Error('Harness interaction reconnect wait failed'); } } } #registerInteractionOwnership(sessionId, ownership) { const owners = this.#interactionOwnerships.get(sessionId) ?? new Set(); ownership.order = this.#interactionRegistry.nextOrder; this.#interactionRegistry.nextOrder += 1; owners.add(ownership); this.#interactionOwnerships.set(sessionId, owners); } #unregisterInteractionOwnership(sessionId, ownership) { const owners = this.#interactionOwnerships.get(sessionId); owners?.delete(ownership); if (owners?.size === 0) this.#interactionOwnerships.delete(sessionId); for (const [key, claim] of this.#interactionClaims) { if (claim.ownership === ownership) this.#interactionClaims.delete(key); } } #consumeInteractionOwnerships(sessionId, entries) { for (const ownership of this.#interactionOwnerships.get(sessionId) ?? []) { consumeInteractionOwnership(ownership, entries); } } async #refreshInteractionOwnerships(sessionId, signal) { const history = await this.rpc( 'session.history', { sessionId, maxMessages: 50 }, 30_000, { signal }, ); this.#consumeInteractionOwnerships(sessionId, history.events ?? []); } #interactionOwner(sessionId, claimKey, kind) { const claim = this.#interactionClaims.get(claimKey); if (claim && this.#interactionOwnerships.get(sessionId)?.has(claim.ownership)) return claim; const owners = [...(this.#interactionOwnerships.get(sessionId) ?? [])]; const active = owners .filter((ownership) => ownership.active) .sort((left, right) => left.order - right.order); if (active.length > 0) return { ownership: active[0], recovered: false }; // A newly attached IM conversation may encounter a question left by // an earlier runtime before its queued prompt starts. Let the oldest such // ask adopt that replay so the Session can recover instead of deadlocking. // Approval adopters receive recovered=true and must reject it without ever // presenting it as approvable; the original actor/route cannot be proven // after a runtime restart. const ownership = owners .filter((ownership) => !ownership.started && !ownership.completed) .sort((left, right) => left.order - right.order)[0] ?? null; return ownership ? { ownership, recovered: true } : null; } async ask(sessionId, text, options = {}) { if (typeof options === 'number') options = { timeoutMs: options }; const timeoutMs = options.timeoutMs ?? 600_000; const signal = options.signal; const onUpdate = typeof options.onUpdate === 'function' ? options.onUpdate : null; const onInteraction = typeof options.onInteraction === 'function' ? options.onInteraction : undefined; const onInteractionResolved = typeof options.onInteractionResolved === 'function' ? options.onInteractionResolved : undefined; await this.ensureRunning({ signal }); const before = await this.rpc( 'session.history', { sessionId, maxMessages: 1 }, 30_000, { signal }, ); const baselineSeq = Math.max(-1, ...(before.events ?? []).map(({ event }) => event.seq ?? -1)); const promptRpcId = `${this.#rpcIdPrefix}-${randomUUID()}`; const tracker = new HarnessReplyTracker({ promptRpcId, afterSeq: baselineSeq }); const interactionController = onInteraction || onInteractionResolved ? new AbortController() : null; const interactionSignal = interactionController ? (signal ? AbortSignal.any([signal, interactionController.signal]) : interactionController.signal) : null; // The mux is host-global. A prompt RPC becomes the owner only when its // durable user/message starts a turn, so two chats bound to one Session // cannot answer each other's questions or approvals. const ownership = interactionController ? { promptRpcId, active: false, started: false, completed: false, turn: null, openTurn: null, lastSeq: baselineSeq, reconnect: null, order: -1, toolCalls: new Map(), } : null; let interactionTask = null; if (ownership) this.#registerInteractionOwnership(sessionId, ownership); try { if (interactionSignal) { let markOpen; const opened = new Promise((resolve) => { markOpen = resolve; }); interactionTask = this.watchInteractions(sessionId, { signal: interactionSignal, onInteraction, onResolved: onInteractionResolved, onOpen: markOpen, ownership, }); void interactionTask.catch(() => undefined); await Promise.race([ opened, sleep(30_000, interactionSignal).then(() => { throw new Error('Harness interaction stream did not open within 30 seconds'); }), ]); } await this.rpc('session.prompt', { sessionId, mode: 'queue', content: [{ type: 'text', text }], clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }, 30_000, { rpcId: promptRpcId, signal }); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(300, signal); const history = await this.rpc( 'session.history', { sessionId, maxMessages: 50 }, 30_000, { signal }, ); const wasActive = ownership?.active === true; if (ownership) { this.#consumeInteractionOwnerships(sessionId, history.events ?? []); if (!wasActive && ownership.active) ownership.reconnect?.(); } const update = tracker.consume(history.events ?? []); if (update && onUpdate) { try { await onUpdate(update); } catch (error) { console.warn(`[${this.#logPrefix}] ignored a progress update failure:`, error.message); } } if (!tracker.finished) continue; if (tracker.answer) return tracker.answer; throw new Error( `Harness turn ended without a text reply${tracker.reason ? ` (${JSON.stringify(tracker.reason)})` : ''}`, ); } throw new Error(`Harness reply timed out after ${Math.round(timeoutMs / 1_000)} seconds`); } finally { interactionController?.abort(new DOMException('Harness turn finished', 'AbortError')); if (interactionTask) await interactionTask.catch(() => undefined); if (ownership) this.#unregisterInteractionOwnership(sessionId, ownership); } } #watchInteractionSocket(sessionId, { signal, onInteraction, onResolved, onOpen, ownership, }) { const url = new URL('/api/events.mux', this.#baseUrl); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; return new Promise((resolve, reject) => { let socket; try { socket = this.#createWebSocket(url.toString()); } catch (error) { reject(error); return; } let opened = false; let settled = false; let callbackFailure = null; let callbackTail = Promise.resolve(); let ownershipReady = ownership === undefined || ownership === null; const bufferedEnvelopes = []; const finish = (error) => { if (settled) return; settled = true; socket.removeEventListener('open', handleOpen); socket.removeEventListener('message', handleMessage); socket.removeEventListener('close', handleClose); socket.removeEventListener('error', handleError); signal.removeEventListener('abort', handleAbort); if (ownership?.reconnect === close) ownership.reconnect = null; void callbackTail.then(() => { const failure = error ?? callbackFailure; if (failure) reject(failure); else resolve(); }, reject); }; const close = () => { try { if (socket.readyState === 0 || socket.readyState === 1) socket.close(); } catch { // Cleanup must still settle the watcher if a WebSocket rejects close while connecting. } }; const handleOpen = () => { opened = true; if (ownership) ownership.reconnect = close; try { onOpen?.(); } catch (error) { console.warn(`[${this.#logPrefix}] ignored an interaction open callback failure:`, error.message); } if (ownership) { void this.#refreshInteractionOwnerships(sessionId, signal).then(() => { if (settled) return; ownershipReady = true; for (const envelope of bufferedEnvelopes.splice(0)) processEnvelope(envelope); }).catch((error) => { callbackFailure ??= error; close(); finish(error); }); } }; const dispatch = (callback, value) => { if (!callback) return; callbackTail = callbackTail .then(() => callback(value)) .catch((error) => { callbackFailure ??= error; close(); finish(callbackFailure); }); }; const processEnvelope = (envelope) => { const payload = envelope.payload; if (ownership && payload.type === 'session/event') { this.#consumeInteractionOwnerships(sessionId, [payload.event]); return; } if (payload.type === 'question/requested' || payload.type === 'approval/requested') { const kind = payload.type === 'question/requested' ? 'question' : 'approval'; const interactionId = kind === 'question' ? envelope.rpcId : payload.approvalId; const claimKey = `${kind}:${interactionId}`; if (ownership) { const claim = this.#interactionOwner(sessionId, claimKey, kind); if (claim?.ownership !== ownership) return; this.#interactionClaims.set(claimKey, claim); } const toolCall = kind === 'approval' && ownership && typeof payload.callId === 'string' ? this.#interactionClaims.get(claimKey)?.ownership.toolCalls.get(payload.callId) : undefined; dispatch(onInteraction, Object.freeze({ kind, interactionId, rpcId: envelope.rpcId, sessionId, payload, recovered: ownership ? this.#interactionClaims.get(claimKey)?.recovered === true : false, ...(toolCall ? { toolCall } : {}), reconnect: close, respond: (result, options = {}) => this.respondInteraction( envelope.rpcId, result, { ...options, signal: options.signal ?? signal }, ), })); return; } if (payload.type === 'question/resolved' || payload.type === 'approval/resolved') { const kind = payload.type === 'question/resolved' ? 'question' : 'approval'; const interactionId = kind === 'question' ? payload.questionRpcId : payload.approvalId; const claimKey = `${kind}:${interactionId}`; if (ownership) { const claim = this.#interactionClaims.get(claimKey); if (claim?.ownership !== ownership) return; this.#interactionClaims.delete(claimKey); } dispatch(onResolved, Object.freeze({ kind, interactionId, sessionId, outcome: payload.outcome, payload, })); } }; const handleMessage = (event) => { try { if (typeof event.data !== 'string') throw new Error('binary WebSocket frame'); const envelope = JSON.parse(event.data); const payload = envelope?.payload; if (envelope?.type !== 'server-request' || typeof envelope.rpcId !== 'string' || !payload || typeof payload !== 'object' || envelope.method !== payload.type) { throw new Error('invalid server-request envelope'); } if (payload.sessionId !== sessionId) return; if (!ownershipReady) bufferedEnvelopes.push(envelope); else processEnvelope(envelope); } catch (error) { console.warn(`[${this.#logPrefix}] ignored a malformed Harness interaction frame:`, error.message); } }; const handleClose = () => finish(opened ? null : new Error( 'Harness interaction WebSocket closed before opening', )); const handleError = () => { finish(new Error(opened ? 'Harness interaction WebSocket failed' : 'Harness interaction WebSocket failed before opening')); close(); }; const handleAbort = () => { close(); finish(); }; socket.addEventListener('open', handleOpen); socket.addEventListener('message', handleMessage); socket.addEventListener('close', handleClose, { once: true }); socket.addEventListener('error', handleError, { once: true }); signal.addEventListener('abort', handleAbort, { once: true }); if (signal.aborted) handleAbort(); }); } stopManagedProcess() { if (this.#managedProcess?.exitCode === null) this.#managedProcess.kill('SIGTERM'); } }