diff --git a/adapter/omp-spt.toml b/adapter/omp-spt.toml index b0e72ab..fd4b0d0 100644 --- a/adapter/omp-spt.toml +++ b/adapter/omp-spt.toml @@ -1,99 +1,107 @@ # omp-spt — Oh My Pi harness adapter for spt-core. # # Native OMP owns every hosted terminal. The adapter binary is a launch, # digest/history, Psyche, and echo-commune helper; OMP's packaged extension owns # bind, delivery, activity state, reply, and shutdown inside the native TUI. # This manifest is authored only against the published spt-core manifest and # CLI contracts. See OMP-ADAPTER-PLAN.md and ADRs 0008-0013. [adapter] name = "omp-spt" kind = "harness" version = "0.3.12" # v0.31.0 added identity-preserving `api listen --session-id`, which keeps the # listener on the native OMP session already recorded by the extension's bind. min_spt_core_version = "0.31.0" # [impl->REQ-OMP-READY-LIVE] hostable_types = ["LiveAgent", "ReadyAgent"] # Only the genuine OMP host process may resolve to this adapter. host_binaries = ["omp"] shortcut_basename = "omp" # Update through this repository's GitHub release. The packaged fat archive # contains all three supported target binaries plus the shared native plugin. # [impl->REQ-DIST-ADAPTER-RELEASE] [update] avenue = "gh_release" repo = "BigscreenVR/omp-spt" transport = "gh" message = """ **omp-spt updated.** The native OMP extension + extractors refreshed in place — no reload step: OMP loads the packaged extension fresh on each endpoint bringup. - Running endpoints keep the OLD hosting path until restarted: `spt endpoint stop ` then `spt endpoint run --adapter omp-spt --id ` picks up the new one. - Bring up a fresh Librarian endpoint: `spt endpoint run --adapter omp-spt --id --create`. """ +# OMP's native extension is the non-disruptive injection seam in both activity +# states. Busy active-only traffic is pulled at context boundaries; idle and +# unrestricted listener traffic uses the same native user-message API. +# [impl->REQ-OMP-CORE-DELIVERY] +[inject] +activity = ["hook"] +idle = ["hook"] + [identity] # OMP reports the bound session id after spawn; the process-tree fallback is # the native host executable, never the adapter helper. session_id_source = "post_spawn" parent_ancestor_name = "omp" [session] # [impl->REQ-OMP-CONTINUITY-DROPS] commune_dir = ".spt" signoff_dir = ".spt" # The base manifest is live-capable. ReadyAgent and LiveAgent use the same OMP # endpoint; only LiveAgent activates the per-event Psyche role. # [impl->REQ-PSYCHE-EPHEMERAL-SHIM] [session.psyche_init] command = "omp-spt psyche-omp --id {id} --session-id {session_id} --psyche-context-file {psyche_context_file}" # Prevent a child Psyche from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id", "psyche_context_file"] # Each Psyche event is one bounded OMP turn. The context file is read by the # shim, the event arrives on stdin, stdout carries the result, and exit 95 asks # [impl->REQ-PSYCHE-EPHEMERAL-SHIM] [session.psyche_resume] command = "omp-spt psyche-omp --id {id} --session-id {session_id} --psyche-context-file {psyche_context_file}" detach = false # Prevent a child Psyche from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id", "psyche_context_file"] # Fresh endpoints launch validated native OMP with the packaged extension. The # launch shim snapshots only non-secret OMP locator/profile/executable selectors # under the endpoint project's `.spt` before OMP takes over the broker PTY. It # also forwards the daemon-advertised node label for operator-facing naming. [session.self] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id", "node"] # Resume refreshes the same endpoint snapshot, then uses OMP's native session # selector with the packaged extension and the same naming inputs. [session.resume] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id", "node", "session_id"] # The bounded summarizer reads the selected OMP session JSONL and runs one # extension-free OMP turn. A missing transcript is an empty delta; a real OMP # failure is reported to spt-core. # [impl->REQ-SESSION-ECHO-COMMUNE] [session.echo_commune] command = "omp-spt echo-commune-omp --id {id} --session-id {session_id}" detach = false recursion_guard_env = "SPT_ECHO_COMMUNE" # Prevent the summarizer from inheriting its parent's SPT identity. env_remove = ["OWL_SESSION_ID", "SPT_AGENT_ID"] keys = ["id", "session_id"] # History is the opaque JSONL for exactly one OMP session. # [impl->REQ-HISTORY-FETCHER] [history] strategy = "fetcher" diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index 3849ce0..0e7d9fd 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -1,81 +1,84 @@ import { spawn } from "node:child_process"; const ADAPTER = "omp-spt"; const BUSY_TITLE_GLYPHS = [..."⣾⣽⣻⢿⡿⣟⣯⣷"]; const IDLE_TITLE_GLYPH = "○"; const TITLE_FRAME_MS = 500; // [impl->REQ-OMP-SESSION-TITLES] export function endpointDisplayName(id, node, project) { const endpoint = String(id ?? "").trim(); const nodeName = String(node ?? "").trim(); const projectName = String(project ?? "").trim(); if (!nodeName) return endpoint; return projectName ? `${endpoint} @ ${nodeName} (${projectName}/)` : `${endpoint} @ ${nodeName}`; } -export function endpointInlineStatus(id, node, project, theme) { +export function endpointInlineStatus(id, node, project, theme, recovering = false) { const text = endpointDisplayName(id, node, project); - return theme?.fg ? theme.fg("statusLineModel", text) : text; + const identity = theme?.fg ? theme.fg("statusLineModel", text) : text; + if (!recovering) return identity; + const warning = " · comms recovering..."; + return identity + (theme?.fg ? theme.fg("warning", warning) : warning); } export function decodeBody(body) { return body .replaceAll("
", "\n") .replaceAll("
", "\n") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll(""", '"') .replaceAll("&", "&"); } function protocolError(message) { const error = new Error(`invalid spt EVENT stream: ${message}`); error.code = "SPT_PROTOCOL_ERROR"; return error; } function parseEventTag(tag) { if (!tag.startsWith("]*)"/.exec(tag.slice(cursor)); if (!match) return { error: protocolError("malformed EVENT attributes") }; const [, name, value] = match; if (Object.hasOwn(attributes, name)) { return { error: protocolError(`duplicate EVENT ${name} attribute`) }; } attributes[name] = value; cursor += match[0].length; } if (!attributes.type) return { error: protocolError("missing EVENT type attribute") }; if (attributes.type === "msg" && !attributes.from) { return { error: protocolError("missing EVENT from attribute") }; } return { attributes }; } function findEventClose(raw, bodyStart) { let cursor = bodyStart; let depth = 1; while (true) { const open = raw.indexOf("", cursor); if (close < 0) return -1; if (open >= 0 && open < close) { const openEnd = raw.indexOf(">", open); if (openEnd < 0 || openEnd >= close) return -1; if (!parseEventTag(raw.slice(open, openEnd)).error) depth += 1; cursor = openEnd + 1; continue; } depth -= 1; if (depth === 0) return close; @@ -161,121 +164,121 @@ function assistantMessageIdentity(message) { return undefined; } function captureAssistantBaseline(messages) { const assistants = (messages ?? []).filter((message) => message?.role === "assistant"); const identities = new Set(); for (const message of assistants) { const identity = assistantMessageIdentity(message); if (identity !== undefined) identities.add(identity); } return { count: assistants.length, identities }; } function assistantAfterBaseline(messages, baseline) { const assistants = (messages ?? []).filter((message) => message?.role === "assistant"); for (let index = assistants.length - 1; index >= 0; index -= 1) { const identity = assistantMessageIdentity(assistants[index]); if (identity !== undefined && !baseline.identities.has(identity)) return assistants[index]; } for (let index = assistants.length - 1; index >= baseline.count; index -= 1) { if (assistantMessageIdentity(assistants[index]) === undefined) return assistants[index]; } return undefined; } const SUCCESSFUL_ASSISTANT_STOP_REASONS = new Set(["stop", "length", "toolUse"]); function successfulAssistant(message) { return SUCCESSFUL_ASSISTANT_STOP_REASONS.has(message?.stopReason); } function firstLine(text) { return text.split(/\r?\n/).find((line) => line.trim()) ?? ""; } function errorSummary(error) { const detail = error instanceof Error ? error.message : String(error); return firstLine(detail).trim() || "unknown error"; } function senderStub(sender) { const escaped = sender .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); return ``; } export function formatInboundEnvelope(envelope) { const openingEnd = envelope.indexOf(">"); const closingStart = envelope.lastIndexOf(""); if (openingEnd < 0 || closingStart <= openingEnd) return envelope; return `${envelope.slice(0, openingEnd + 1)}\n${envelope.slice(openingEnd + 1, closingStart)}\n${envelope.slice(closingStart)}`; } function injectEnvelope(messages, item) { - const index = messages.findLastIndex( + const index = messages.findIndex( (message) => message?.role === "user" && messageText(message) === item.stub, ); if (index < 0) return messages; const original = messages[index]; const envelope = formatInboundEnvelope(item.envelope); const content = typeof original.content === "string" ? `${original.content}\n\n${envelope}` : [...(original.content ?? []), { type: "text", text: `\n\n${envelope}` }]; const injected = [...messages]; injected[index] = { ...original, content }; return injected; } function maskMarkdownCode(text) { const source = String(text); const masked = source.split(""); let fenceCharacter; let fenceLength = 0; let lineStart = 0; while (lineStart < source.length) { const newline = source.indexOf("\n", lineStart); const lineEnd = newline < 0 ? source.length : newline + 1; const line = source.slice(lineStart, newline < 0 ? lineEnd : newline).replace(/\r$/, ""); const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line); const indentedCode = !fenceCharacter && /^(?: {4}|\t)/.test(line); let maskLine = Boolean(fenceCharacter || fence || indentedCode); if (fence) { const character = fence[1][0]; if (!fenceCharacter) { fenceCharacter = character; fenceLength = fence[1].length; } else if (character === fenceCharacter && fence[1].length >= fenceLength) { fenceCharacter = undefined; fenceLength = 0; } else { maskLine = true; } } if (maskLine) { for (let index = lineStart; index < lineEnd; index += 1) { if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; } } lineStart = lineEnd; } for (let cursor = 0; cursor < source.length; cursor += 1) { if (masked[cursor] !== "`") continue; let runLength = 1; while (masked[cursor + runLength] === "`") runLength += 1; const delimiter = "`".repeat(runLength); let closing = source.indexOf(delimiter, cursor + runLength); while ( closing >= 0 && (source[closing - 1] === "`" || source[closing + runLength] === "`") ) { closing = source.indexOf(delimiter, closing + runLength); } if (closing < 0) { @@ -719,178 +722,205 @@ export function createOmpSpt(overrides = {}) { normalized = normalized.slice(0, -1); } return platform === "win32" ? normalized.toLowerCase() : normalized; } function latestDigestTimestamp(digest) { let latest = Number.NEGATIVE_INFINITY; for (const turn of digest?.turns ?? []) { for (const entry of turn?.entries ?? []) { for (const value of Object.values(entry ?? {})) { const parsed = Date.parse(value?.ts ?? ""); if (Number.isFinite(parsed)) latest = Math.max(latest, parsed); } } } return latest; } function newestCoreUpdate(rawNotifications, currentVersion) { const notifications = parseJson(rawNotifications, "spt notif list").notifs ?? []; let newest; for (const notification of notifications) { if ( notification?.from_id !== "spt-update" || notification?.kind !== "consent" || notification?.state === "dismissed" ) { continue; } const version = parseVersion(notification.head); if (!version || compareVersions(version, currentVersion) <= 0) continue; if (!newest || compareVersions(version, newest) > 0) newest = version; } return newest; } return function ompSpt(pi) { let id = env.SPT_ENDPOINT_ID?.trim() || undefined; const initialId = id; let activationType; let activationPromise; let activationCommandsInFlight = 0; let activated = false; let startupBriefPending = false; let updateNoticesPromise = Promise.resolve([]); let updateNotices = []; let updateNoticesReady = false; let updateNoticesPending = false; let firstTurnContextStarted = false; let cachedReadyIds = []; let cachedLiveIds = []; let sid; let token; let listener; let listenerBuffer = ""; let listenerRestartCount = 0; let listenerStableTimer; let restartTimer; - let dispatchTimer; + let stateRetryTimer; + let stateRetryAttempt = 0; let bindPromise; let agentActive = false; let desiredState = "idle"; - let dispatching = false; let turnCompletionPromise; let turnAssistantBaseline = captureAssistantBaseline([]); let turnContextObserved = false; let observedAssistantBaseline = captureAssistantBaseline([]); let listenerTerminationPromise; let shutdownMode = false; let shutdownDeadlineExpired = false; const activeCommands = new Map(); const retryWaiters = new Set(); - let current; let stopping = false; let ui; let titleTimer; let titleFrame = 0; const displayName = () => endpointDisplayName(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT); const setWindowTitle = (glyph) => ui?.setTitle(`${glyph} ${displayName()}`); const stopTitleAnimation = () => { if (titleTimer !== undefined) { clearRepeatingTimer(titleTimer); titleTimer = undefined; } titleFrame = 0; }; const showIdleTitle = () => { stopTitleAnimation(); setWindowTitle(IDLE_TITLE_GLYPH); }; const showBusyTitle = () => { stopTitleAnimation(); setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]); titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length; titleTimer = setRepeatingTimer(() => { setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]); titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length; }, TITLE_FRAME_MS); titleTimer?.unref?.(); }; let runtimeCtx; let endpointState; - let stateOperation = Promise.resolve(); let endPromise; let fatalPromise; let teardownPromise; let acceptedBytes = 0; let overflowItem; - const queue = []; + const pendingListener = []; + const commsFailures = new Set(); const logError = (message, error) => { pi.logger.error(message, { error: errorSummary(error) }); ui?.notify(`${message}: ${errorSummary(error)}`, "error"); }; + function renderEndpointStatus() { + if (!id || !ui) return; + ui.setStatus( + "omp-spt", + endpointInlineStatus( + id, + env.OMP_SPT_NODE, + env.OMP_SPT_PROJECT, + ui.theme, + commsFailures.size > 0, + ), + ); + } + + // [impl->REQ-OMP-COMMS-RECOVERY] + function markCommsFailure(component, message, error) { + const first = !commsFailures.has(component); + commsFailures.add(component); + pi.logger.error(message, { error: errorSummary(error) }); + if (first) ui?.notify(`${message}: ${errorSummary(error)}`, "warning"); + renderEndpointStatus(); + } + + function clearCommsFailure(component) { + if (!commsFailures.delete(component)) return; + renderEndpointStatus(); + } + function runCommand(args, input, options = {}) { if (shutdownDeadlineExpired) { return Promise.reject(new Error("omp-spt shutdown deadline expired")); } const timeoutMs = options.timeoutMs ?? (shutdownMode ? shutdownCommandTimeoutMs : commandTimeoutMs); const controller = new AbortController(); activeCommands.set(controller, { args, abortTimer: undefined }); let command; try { command = Promise.resolve( runSptCommand(args, input, { signal: controller.signal, timeoutMs, }), ); } catch (error) { activeCommands.delete(controller); return Promise.reject(error); } if (customRunSptCommand) { const rawCommand = command; command = new Promise((resolve, reject) => { let timer; let finished = false; const finish = (error, value) => { if (finished) return; finished = true; if (timer !== undefined) clearTimer(timer); controller.signal.removeEventListener("abort", onAbort); if (error === undefined) resolve(value); else reject(error); }; const onAbort = () => finish( controller.signal.reason instanceof Error ? controller.signal.reason : new Error(`${commandLabel(args)} aborted`), ); controller.signal.addEventListener("abort", onAbort, { once: true }); if (shutdownMode) { timer = setTimer( () => controller.abort( new Error(`${commandLabel(args)} timed out after ${timeoutMs}ms`), ), timeoutMs, ); timer?.unref?.(); } rawCommand.then( (value) => finish(undefined, value), (error) => finish(error), ); }); } return command.finally(() => { const active = activeCommands.get(controller); if (active?.abortTimer !== undefined) clearTimer(active.abortTimer); activeCommands.delete(controller); @@ -1101,168 +1131,170 @@ export function createOmpSpt(overrides = {}) { ) ).filter((candidate) => Number.isFinite(candidate.lastActiveAt)); recent.sort( (left, right) => right.lastActiveAt - left.lastActiveAt || left.id.localeCompare(right.id), ); if (recent.length === 0) { ctx.ui.notify( "No compatible prior omp-spt live identity has recorded activity; use `/live `", "error", ); return undefined; } let candidate = recent[0]; const ties = recent.filter((item) => item.lastActiveAt === candidate.lastActiveAt); if (ties.length > 1) { const selected = await ctx.ui.select( "Select equally recent live endpoint", ties.map((item) => item.id), ); if (!selected) return undefined; candidate = ties.find((item) => item.id === selected); } const confirmed = await ctx.ui.confirm( "Resume live endpoint?", `${candidate.id} was the most recently active compatible live endpoint (${new Date( candidate.lastActiveAt, ).toISOString()}). Bind this OMP session to it?`, ); return confirmed ? candidate.id : undefined; } // [impl->REQ-PARITY-READY-ACTIVATION] // [impl->REQ-PARITY-LIVE-ACTIVATION] async function activateEndpoint(nextId, type, ctx, options = {}) { if (!endpointIdPattern.test(nextId ?? "")) { ctx.ui.notify( "SPT endpoint ids may contain only letters, numbers, `-`, and `_`", "error", ); return false; } if (activated || token) { ctx.ui.notify( `This OMP session is immutably bound to ${id}; stop it before activating another identity`, "warning", ); return false; } if (stopping) { ctx.ui.notify("This OMP session is already shutting down", "error"); return false; } if (activationPromise) return activationPromise; runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); id = nextId; activationType = type; - let bound = false; const operation = (async () => { const bindArgs = [ "api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid, ]; if (type) bindArgs.push("--type", type); if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); bindPromise = (async () => { const response = await runCommand(bindArgs); token = response.match(/\btoken=([^\s]+)/)?.[1]; if (!token) throw new Error("spt bind response did not include token="); - bound = true; })(); try { await bindPromise; - await syncDesiredState(); + try { + await syncDesiredState(); + } catch { + // Bind established the endpoint; communications now recover in place. + } if (stopping) { await teardownSession("OMP session shut down before initialization completed"); return false; } activated = true; startupBriefPending = true; ui.setStatus( "omp-spt", endpointInlineStatus(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT, ui.theme), ); // [impl->REQ-OMP-SESSION-TITLES] pi.setSessionName(displayName()); showIdleTitle(); startListener(); beginUpdateProbe(); if (options.announce) { ui.notify( `OMP session activated as ${type === "live_agent" ? "live" : "ready"} endpoint ${id}`, "info", ); } return true; } catch (error) { ui.setStatus("omp-spt", "spt bind failed"); - if (options.fatal || bound) { - await failClosed(`omp-spt could not bind ${id}`, error); + if (options.fatal) { + await failActivation(`omp-spt could not bind ${id}`, error); return false; } pi.logger.error(`omp-spt could not bind ${id}`, { error: errorSummary(error), }); ui.notify(`omp-spt could not bind ${id}: ${errorSummary(error)}`, "error"); id = undefined; activationType = undefined; bindPromise = undefined; token = undefined; return false; } })(); activationPromise = operation; try { return await operation; } finally { if (!activated && activationPromise === operation) activationPromise = undefined; } } async function handleActivationCommand(type, args, ctx) { activationCommandsInFlight += 1; try { const command = type === "live_agent" ? "live" : "ready"; const trimmed = args.trim(); let nextId; if (trimmed === "--auto") { if (type !== "live_agent") { ctx.ui.notify("`--auto` is supported only by `/live`", "error"); return; } nextId = await chooseAutoResume(ctx); } else if (!trimmed) { nextId = await chooseIdentity(type, ctx); } else if (/\s/.test(trimmed) || trimmed.startsWith("-")) { ctx.ui.notify( `Usage: /${command} ${command === "live" ? " | --auto" : ""}`, "error", ); return; } else { nextId = trimmed; } if (!nextId) return; await activateEndpoint(nextId, type, ctx, { announce: true }); } finally { activationCommandsInFlight -= 1; } } pi.registerCommand("ready", { description: "Activate this OMP session as a ready SPT endpoint", getArgumentCompletions: commandCompletions(cachedReadyIds, false), handler: (args, ctx) => handleActivationCommand("ready_agent", args, ctx), }); pi.registerCommand("live", { description: "Activate this OMP session as a live SPT endpoint", getArgumentCompletions: commandCompletions(cachedLiveIds, true), handler: (args, ctx) => handleActivationCommand("live_agent", args, ctx), @@ -1352,605 +1384,638 @@ export function createOmpSpt(overrides = {}) { }; } catch (error) { return { content: [ { type: "text", text: `SPT checkpoint failed: ${errorSummary(error)}`, }, ], details: { ok: false, reason: errorSummary(error) }, isError: true, }; } }, }); function abortActiveCommands(reason, allowBindGrace = false) { for (const [controller, active] of activeCommands) { const isBind = active.args[0] === "api" && active.args[3] === "bind"; if (allowBindGrace && isBind && active.abortTimer === undefined) { active.abortTimer = setTimer( () => controller.abort(reason), shutdownCommandTimeoutMs, ); active.abortTimer?.unref?.(); continue; } controller.abort(reason); } } function waitForRetry(delay) { if (shutdownMode) return Promise.resolve(); return new Promise((resolve) => { let timer; const finish = () => { if (timer !== undefined) clearTimer(timer); retryWaiters.delete(finish); resolve(); }; retryWaiters.add(finish); timer = setTimer(finish, delay); timer?.unref?.(); }); } function enterShutdownMode() { if (shutdownMode) return; shutdownMode = true; const reason = new Error("omp-spt command interrupted for bounded shutdown"); abortActiveCommands(reason, true); for (const finish of [...retryWaiters]) finish(); } function authArgs() { if (!token) throw new Error("bind did not return an authentication token"); return ["--token", token]; } - function setState(state) { - if (!sid || !token || stopping) return Promise.resolve(); - const operation = stateOperation.catch(() => {}).then(async () => { - if (endpointState === state || stopping) return; + function scheduleStateRetry() { + if (stopping || stateRetryTimer !== undefined) return; + const index = Math.min(stateRetryAttempt, Math.max(0, restartDelaysMs.length - 1)); + const delay = restartDelaysMs[index] ?? 0; + stateRetryAttempt += 1; + stateRetryTimer = setTimer(() => { + stateRetryTimer = undefined; + if (stopping) return; + void setState(desiredState).catch(() => {}); + }, delay); + stateRetryTimer?.unref?.(); + } + + // [impl->REQ-OMP-COMMS-RECOVERY] + async function setState(state) { + if (!sid || !token || stopping) return; + if (endpointState === state) { + if (state === desiredState) clearCommsFailure("state"); + return; + } + try { await runCommand(["api", "--adapter", ADAPTER, "state", state, id, ...authArgs()]); endpointState = state; - }); - stateOperation = operation; - return operation; + if (state === desiredState) { + stateRetryAttempt = 0; + if (stateRetryTimer !== undefined) { + clearTimer(stateRetryTimer); + stateRetryTimer = undefined; + } + clearCommsFailure("state"); + } else { + markCommsFailure( + "state", + "omp-spt activity changed during state publication; reconciling", + new Error(`published stale ${state} state while current OMP state is ${desiredState}`), + ); + scheduleStateRetry(); + } + } catch (error) { + markCommsFailure("state", `omp-spt could not mark the endpoint ${state}`, error); + scheduleStateRetry(); + throw error; + } } async function syncDesiredState() { if (!bindPromise) return; await bindPromise; - while (!stopping && endpointState !== desiredState) { - await setState(desiredState); - } + await setState(desiredState); } function endSession() { if (!sid || !token) return Promise.resolve(); if (!endPromise) { const operation = (async () => { - await stateOperation.catch(() => {}); await runCommand(["api", "--adapter", ADAPTER, "session-end", id, ...authArgs()]); endpointState = undefined; })(); endPromise = operation; void operation.catch(() => { if (endPromise === operation) endPromise = undefined; }); } return endPromise; } async function endSessionWithRetry() { for (let attempt = 0; ; attempt += 1) { try { await endSession(); return; } catch (error) { if (shutdownMode || attempt >= sessionEndRetryDelaysMs.length) throw error; const delay = sessionEndRetryDelaysMs[attempt]; pi.logger.error( `omp-spt session teardown failed; retrying ${ attempt + 1 }/${sessionEndRetryDelaysMs.length} in ${delay}ms`, { error: errorSummary(error) }, ); await waitForRetry(delay); } } } function releaseItem(item) { if (!item?.accounted) return; item.accounted = false; acceptedBytes -= item.acceptedBytes; } function beginListenerTermination(child, label) { if (listenerTerminationPromise) return listenerTerminationPromise; const operation = (async () => { try { await terminateChild(child, label, { clearTimer, forceMs: killForceMs, graceMs: killGraceMs, setTimer, }); } catch (error) { pi.logger.error("omp-spt could not reap the listener", { error: errorSummary(error), }); } })(); listenerTerminationPromise = operation; void operation.then(() => { if (listenerTerminationPromise === operation) listenerTerminationPromise = undefined; }); return operation; } async function stopResources() { stopTitleAnimation(); - if (dispatchTimer !== undefined) { - clearTimer(dispatchTimer); - dispatchTimer = undefined; + if (stateRetryTimer !== undefined) { + clearTimer(stateRetryTimer); + stateRetryTimer = undefined; } if (restartTimer !== undefined) { clearTimer(restartTimer); restartTimer = undefined; } if (listenerStableTimer !== undefined) { clearTimer(listenerStableTimer); listenerStableTimer = undefined; } const child = listener; listener = undefined; listenerBuffer = ""; if (child) { await beginListenerTermination(child, "spt api listener"); } else { await listenerTerminationPromise; } } function releasePending() { - const pending = current ? [current, ...queue] : [...queue]; + const pending = [...pendingListener]; if (overflowItem) pending.push(overflowItem); - current = undefined; - queue.length = 0; + pendingListener.length = 0; overflowItem = undefined; for (const item of pending) releaseItem(item); } function teardownSession(pendingReason) { if (!teardownPromise) { stopping = true; const operation = (async () => { releasePending(); await bindPromise?.catch(() => {}); try { await endSessionWithRetry(); } finally { await stopResources(); } })(); teardownPromise = operation; void operation.catch(() => { if (teardownPromise === operation) teardownPromise = undefined; }); } return teardownPromise; } async function shutdownWithinBudget(pendingReason) { enterShutdownMode(); const teardown = teardownSession(pendingReason); let budgetTimer; const expired = new Promise((resolve) => { budgetTimer = setTimer(() => { budgetTimer = undefined; shutdownDeadlineExpired = true; const error = new Error( `omp-spt shutdown exceeded its ${shutdownBudgetMs}ms budget`, ); abortActiveCommands(error); for (const finish of [...retryWaiters]) finish(); resolve(false); }, shutdownBudgetMs); budgetTimer?.unref?.(); }); const completed = teardown.then( () => true, (error) => { logError("omp-spt session teardown failed", error); return true; }, ); const finished = await Promise.race([completed, expired]); if (budgetTimer !== undefined) clearTimer(budgetTimer); if (!finished) { pi.logger.error("omp-spt bounded shutdown expired", { error: `${shutdownBudgetMs}ms budget exhausted`, }); } } - // [impl->REQ-OMP-LISTENER-FAIL-CLOSED] - async function failClosed(message, error) { + async function failActivation(message, error) { if (stopping && shutdownMode) return teardownPromise ?? Promise.resolve(); if (fatalPromise) return fatalPromise; fatalPromise = (async () => { - ui?.setStatus("omp-spt", "spt failed"); + ui?.setStatus("omp-spt", "spt activation failed"); logError(message, error); try { - await teardownSession("endpoint stopped before your message could complete"); + await teardownSession("endpoint activation failed"); } catch (teardownError) { logError("omp-spt session teardown failed", teardownError); } runtimeCtx?.shutdown(); })(); return fatalPromise; } - function scheduleDispatch() { - if ( - stopping || - dispatching || - current || - queue.length === 0 || - dispatchTimer !== undefined - ) { - return; + // [impl->REQ-OMP-CORE-DELIVERY] + function submitListenerItem(item) { + if (stopping) return false; + item.stub ??= senderStub(item.from ?? "unknown"); + try { + pi.sendUserMessage(item.stub); + return true; + } catch (error) { + const index = pendingListener.indexOf(item); + if (index >= 0) pendingListener.splice(index, 1); + releaseItem(item); + logError("omp-spt could not submit your message to OMP", error); + return false; } - dispatchTimer = setTimer(() => { - dispatchTimer = undefined; - void dispatchNext().catch((error) => { - if (!stopping) return failClosed("omp-spt dispatch failed", error); - }); - }, 0); - dispatchTimer?.unref?.(); } - async function rejectItem(item, reason, error) { - if (stopping) return; - logError(`omp-spt ${reason}`, error); - if (current === item) { - current = undefined; - releaseItem(item); - } - if (!stopping) { - desiredState = "idle"; - try { - await setState("idle"); - } catch (stateError) { - await failClosed( - "omp-spt could not restore idle state after a failed submission", - stateError, - ); - } - } + function resubmitUnobservedListenerItems() { + for (const item of pendingListener) submitListenerItem(item); } - // [impl->REQ-OMP-EXTENSION-CUSTODY] - async function dispatchNext() { - if (stopping || dispatching || current || queue.length === 0) return; - dispatching = true; - const item = queue.shift(); - current = item; - try { - try { - desiredState = "busy"; - await setState("busy"); - } catch (error) { - await rejectItem(item, "could not accept your message", error); - return; - } - if (stopping) return; - item.stub = senderStub(item.from ?? "unknown"); - item.submitted = true; - item.hiddenSubmitted = false; - try { - pi.sendUserMessage(item.stub); - } catch (error) { - item.submitted = false; - await rejectItem(item, "could not submit your message to OMP", error); - } - } finally { - dispatching = false; - scheduleDispatch(); - } + function admitOverflowItem() { + if (!overflowItem || pendingListener.length >= acceptedQueueLimit) return; + const item = overflowItem; + overflowItem = undefined; + pendingListener.push(item); + submitListenerItem(item); } - // [impl->REQ-OMP-LISTENER-FAIL-CLOSED] + // [impl->REQ-OMP-COMMS-RECOVERY] function handleListenerDeath(reason) { listenerBuffer = ""; if (listenerStableTimer !== undefined) { clearTimer(listenerStableTimer); listenerStableTimer = undefined; } if (stopping || restartTimer !== undefined) return; - if (listenerRestartCount >= restartDelaysMs.length) { - void failClosed("omp-spt listener restart budget exhausted", reason); - return; - } const attempt = listenerRestartCount + 1; - const delay = restartDelaysMs[listenerRestartCount]; + const index = Math.min(listenerRestartCount, Math.max(0, restartDelaysMs.length - 1)); + const delay = restartDelaysMs[index] ?? 0; listenerRestartCount = attempt; - const message = `omp-spt listener stopped; restarting ${attempt}/${restartDelaysMs.length} in ${delay}ms`; - pi.logger.error(message, { error: errorSummary(reason) }); - ui?.setStatus("omp-spt", `spt reconnecting (${attempt}/${restartDelaysMs.length})`); - ui?.notify(message, "warning"); + markCommsFailure( + "listener", + `omp-spt listener stopped; retrying in ${delay}ms`, + reason, + ); restartTimer = setTimer(() => { restartTimer = undefined; startListener(); }, delay); restartTimer?.unref?.(); } function startListener() { if (stopping) return; const args = ["api", "--adapter", ADAPTER, "listen", id, "--session-id", sid]; if (env.OMP_SPT_SUBNET) args.push("--subnet", env.OMP_SPT_SUBNET); let child; try { child = spawnProcess(env.OMP_SPT_SPT_BIN || "spt", args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); } catch (error) { handleListenerDeath(error); return; } listener = child; listenerBuffer = ""; let dead = false; const died = (reason, alreadyExited) => { if (dead) return; dead = true; if (listener === child) listener = undefined; if (stopping || alreadyExited) { handleListenerDeath(reason); return; } const termination = beginListenerTermination(child, "dead spt api listener"); void termination.then(() => handleListenerDeath(reason)); }; if (listenerStableMs !== undefined) { listenerStableTimer = setTimer(() => { listenerStableTimer = undefined; - if (listener === child && !stopping) listenerRestartCount = 0; + if (listener === child && !stopping) { + listenerRestartCount = 0; + clearCommsFailure("listener"); + } }, listenerStableMs); listenerStableTimer?.unref?.(); } child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", (chunk) => { if (listener !== child || stopping) return; listenerBuffer += String(chunk); if (listenerBuffer.length > listenerBufferLimit) { - void failClosed( - "omp-spt listener protocol corruption", - protocolError( - `EVENT buffer exceeded ${listenerBufferLimit} characters without a complete drain`, - ), + const error = protocolError( + `EVENT buffer exceeded ${listenerBufferLimit} characters without a complete drain`, ); + markCommsFailure("listener", "omp-spt listener protocol corruption", error); + died(error, false); return; } while (!stopping) { const drained = drainEvents(listenerBuffer, { maxEvents: 1, maxFrameChars: listenerBufferLimit, }); listenerBuffer = drained.rest; if (drained.error) { - void failClosed("omp-spt listener protocol corruption", drained.error); + markCommsFailure( + "listener", + "omp-spt listener protocol corruption", + drained.error, + ); + died(drained.error, false); return; } if (drained.events.length === 0) break; const event = drained.events[0]; - const acceptedCount = queue.length + (current ? 1 : 0); + const acceptedCount = pendingListener.length + (overflowItem ? 1 : 0); const eventBytes = Buffer.byteLength(event.envelope, "utf8"); + event.acceptedBytes = eventBytes; + event.accounted = true; + acceptedBytes += eventBytes; if ( acceptedCount >= acceptedQueueLimit || - acceptedBytes + eventBytes > acceptedBytesLimit + acceptedBytes > acceptedBytesLimit ) { overflowItem = event; - void failClosed( - "omp-spt inbound custody capacity exceeded", - new Error( - `accepted queue limit is ${acceptedQueueLimit} messages and ${acceptedBytesLimit} bytes`, - ), + const error = new Error( + `accepted listener limit is ${acceptedQueueLimit} messages and ${acceptedBytesLimit} bytes`, + ); + markCommsFailure( + "listener", + "omp-spt inbound listener capacity exceeded", + error, ); + died(error, false); return; } - event.acceptedBytes = eventBytes; - event.accounted = true; - acceptedBytes += eventBytes; - queue.push(event); + pendingListener.push(event); + submitListenerItem(event); } - if (!current && !dispatching) void dispatchNext(); }); child.stderr.on("data", (chunk) => pi.logger.debug("omp-spt listener", { output: String(chunk).trim() }), ); child.on("error", (error) => died(error, false)); child.on("close", (code, signal) => { const status = signal ? `signal ${signal}` : code; died(new Error(`spt api listen exited ${status}`), true); }); - ui?.setStatus( - "omp-spt", - endpointInlineStatus(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT, ui?.theme), - ); + renderEndpointStatus(); } // [impl->REQ-OMP-NATIVE-TUI] pi.on("session_start", async (_event, ctx) => { runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); if (!initialId) return; await activateEndpoint(initialId, undefined, ctx, { fatal: true }); }); // [impl->REQ-OMP-SESSION-IMMUTABLE] const blockSessionChange = (description, ctx) => { if ( !activated && !token && !activationPromise && activationCommandsInFlight === 0 ) { return; } ctx.ui.notify( `omp-spt blocked the in-TUI ${description}; end this SPT session first`, "warning", ); return { cancel: true }; }; pi.on("session_before_switch", (event, ctx) => blockSessionChange(`${event.reason} session switch`, ctx), ); pi.on("session_before_branch", (_event, ctx) => blockSessionChange("session branch", ctx), ); // [impl->REQ-OMP-MESSAGE-CONTEXT] // [impl->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] - pi.on("context", (event) => { + // [impl->REQ-OMP-CORE-DELIVERY] + pi.on("context", async (event) => { const baseline = captureAssistantBaseline(event.messages); if (agentActive) { if (!turnContextObserved) { turnAssistantBaseline = baseline; turnContextObserved = true; } } else { observedAssistantBaseline = baseline; } let messages = event.messages; - if (current?.submitted && !current.hiddenSubmitted) { - current.assistantBaseline ??= baseline; - messages = injectEnvelope(messages, current); + for (const item of [...pendingListener]) { + const injected = injectEnvelope(messages, item); + if (injected === messages) continue; + messages = injected; + const index = pendingListener.indexOf(item); + if (index >= 0) pendingListener.splice(index, 1); + releaseItem(item); + } + admitOverflowItem(); + + if (activated && !stopping && (agentActive || desiredState === "busy")) { + try { + await setState("busy"); + } catch { + return messages === event.messages ? undefined : { messages }; + } + try { + const polled = await runCommand([ + "api", + "--adapter", + ADAPTER, + "poll", + id, + "--include-deferred", + ...authArgs(), + ]); + clearCommsFailure("poll"); + if (String(polled).trim()) { + messages = [ + ...messages, + { + role: "custom", + customType: "spt-event", + content: String(polled), + display: false, + attribution: "user", + timestamp: Date.now(), + }, + ]; + } + } catch (error) { + markCommsFailure("poll", "omp-spt could not poll active messages", error); + } } if (messages !== event.messages) return { messages }; }); // [impl->REQ-PARITY-STARTUP-BRIEF] // [impl->REQ-PARITY-TARGETED-HINTS] // [impl->REQ-PARITY-UPDATE-NOTICE] - pi.on("before_agent_start", (event) => { + pi.on("before_agent_start", async (event) => { if (!activated || !id || stopping) return; + desiredState = "busy"; + try { + await setState("busy"); + } catch { + // Local model work proceeds while the state retry loop restores routing truth. + } const additions = []; if (startupBriefPending) { startupBriefPending = false; additions.push(startupBrief(id)); } const hints = promptHints(event.prompt); if (hints.length > 0) additions.push(`OMP SPT targeted hints:\n- ${hints.join("\n- ")}`); if (updateNoticesPending && updateNoticesReady) { updateNoticesPending = false; if (updateNotices.length > 0) { additions.push(`OMP SPT updates:\n- ${updateNotices.join("\n- ")}`); } } firstTurnContextStarted = true; if (additions.length === 0) return; return { systemPrompt: [...(event.systemPrompt ?? []), additions.join("\n\n")], }; }); // [impl->REQ-PARITY-PEER-SHORTFORM] async function dispatchShortforms(assistantOutput) { const shortforms = parsePeerShortforms(assistantOutput); if (shortforms.length === 0 || stopping) return; const deliveries = shortforms.flatMap((shortform) => shortform.targets.map((target) => ({ target, body: shortform.body })), ); await mapWithConcurrency( deliveries, shortformConcurrency, async ({ target, body }) => { if (!id) return `${target}: failed (activate this OMP session first)`; try { const result = await runCommand(["send", target, "--from", id], body, { timeoutMs: shortformCommandTimeoutMs, }); return `${target}: ${firstLine(result) || "sent"}`; } catch (error) { return `${target}: failed (${errorSummary(error)})`; } }, ); if (stopping) return; } // [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function completeTurn(event) { agentActive = false; showIdleTitle(); desiredState = "idle"; if (stopping) return; const messages = event.messages ?? []; const currentTurnAssistant = assistantAfterBaseline(messages, turnAssistantBaseline); - const completed = current; const currentTurnReply = successfulAssistant(currentTurnAssistant) ? messageText(currentTurnAssistant) : ""; - if (current === completed) { - current = undefined; - releaseItem(completed); - } observedAssistantBaseline = captureAssistantBaseline(messages); try { await setState("idle"); - } catch (error) { - if (stopping) return; - await failClosed("omp-spt could not mark the endpoint idle", error); - return; + } catch { + // Local completion wins; the retry loop converges on current idle truth. } await dispatchShortforms(currentTurnReply); - if (!stopping) await dispatchNext(); + if (!stopping) resubmitUnobservedListenerItems(); } pi.on("agent_start", async () => { if (stopping) return; turnCompletionPromise = undefined; turnAssistantBaseline = observedAssistantBaseline; turnContextObserved = false; agentActive = true; showBusyTitle(); desiredState = "busy"; try { await syncDesiredState(); - } catch (error) { - if (!stopping) await failClosed("omp-spt could not mark the endpoint busy", error); + } catch { + // before_agent_start already opened recovery; never terminate local work. } }); - // [impl->REQ-OMP-EXTENSION-CUSTODY] + // [impl->REQ-OMP-CORE-DELIVERY] pi.on("agent_end", (event) => { turnCompletionPromise ??= completeTurn(event); return turnCompletionPromise; }); pi.on("session_stop", async (event) => { turnCompletionPromise ??= completeTurn(event); await turnCompletionPromise; }); pi.on("session_shutdown", async (_event, ctx) => { runtimeCtx ??= ctx; ui?.setStatus("omp-spt", undefined); await shutdownWithinBudget("OMP session shut down before your message could complete"); }); }; } export default createOmpSpt();