diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs
index e6ef979..5acab0d 100644
--- a/adapter/strings/omp-spt.mjs
+++ b/adapter/strings/omp-spt.mjs
@@ -1,200 +1,470 @@
import { spawn } from "node:child_process";
const ADAPTER = "omp-spt";
export function decodeBody(body) {
return body
.replaceAll("
", "\n")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", '"')
.replaceAll("&", "&");
}
function attribute(tag, name) {
const marker = ` ${name}="`;
const start = tag.indexOf(marker);
if (start < 0) return undefined;
const valueStart = start + marker.length;
const end = tag.indexOf('"', valueStart);
return end < 0 ? undefined : tag.slice(valueStart, end);
}
export function drainEvents(raw) {
const events = [];
let cursor = 0;
while (true) {
const start = raw.indexOf("", start);
if (openEnd < 0) return { events, rest: raw.slice(start) };
const close = raw.indexOf("", openEnd + 1);
if (close < 0) return { events, rest: raw.slice(start) };
+ const end = close + "".length;
const tag = raw.slice(start, openEnd);
if (attribute(tag, "type") === "msg") {
events.push({
from: attribute(tag, "from"),
body: decodeBody(raw.slice(openEnd + 1, close)),
+ envelope: raw.slice(start, end),
});
}
- cursor = close + "".length;
+ cursor = end;
}
}
-export function extractReply(messages) {
- const assistant = [...(messages ?? [])].reverse().find((message) => message?.role === "assistant");
- if (!assistant) return "";
- if (typeof assistant.content === "string") return assistant.content;
- return (assistant.content ?? [])
+function messageText(message) {
+ if (typeof message?.content === "string") return message.content;
+ return (message?.content ?? [])
.filter((part) => part?.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("");
}
+export function extractReply(messages, afterUserMessage) {
+ const allMessages = messages ?? [];
+ let start = 0;
+ if (afterUserMessage !== undefined) {
+ const userIndex = allMessages.findLastIndex((message) => {
+ if (message?.role !== "user") return false;
+ const text = messageText(message);
+ return text === afterUserMessage || text.startsWith(`${afterUserMessage}\n\n message?.role === "assistant");
+ return assistant ? messageText(assistant) : "";
+}
+
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 failureMessage(reason, error) {
+ const detail = error === undefined ? "" : `: ${errorSummary(error)}`;
+ return `[omp-spt] ${reason}${detail}`;
+}
+
+function senderStub(sender) {
+ const escaped = sender
+ .replaceAll("&", "&")
+ .replaceAll('"', """)
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+ return ``;
+}
+
+function injectEnvelope(messages, item) {
+ const index = messages.findLastIndex(
+ (message) => message?.role === "user" && messageText(message) === item.stub,
+ );
+ if (index < 0) return messages;
+ const original = messages[index];
+ const content =
+ typeof original.content === "string"
+ ? `${original.content}\n\n${item.envelope}`
+ : [...(original.content ?? []), { type: "text", text: `\n\n${item.envelope}` }];
+ const injected = [...messages];
+ injected[index] = { ...original, content };
+ return injected;
+}
+
function runSpt(args, input) {
return new Promise((resolve, reject) => {
const child = spawn(process.env.OMP_SPT_SPT_BIN || "spt", args, {
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
windowsHide: true,
});
let output = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => (output += chunk));
child.stderr.on("data", (chunk) => (output += chunk));
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(output.trim());
else reject(new Error(`spt exited ${code}: ${firstLine(output)}`));
});
if (input !== undefined) child.stdin.end(input);
});
}
-// [impl->REQ-OMP-NATIVE-TUI]
-export default function ompSpt(pi) {
- const id = process.env.SPT_ENDPOINT_ID?.trim();
- if (!id) return;
-
- let sid;
- let token;
- let listener;
- let listenerBuffer = "";
- let agentActive = false;
- let current;
- let stopping = false;
- let ui;
- const queue = [];
-
- const logError = (message, error) => {
- pi.logger.error(message, { error: String(error) });
- ui?.notify(`${message}: ${error}`, "error");
- };
+export function createOmpSpt(overrides = {}) {
+ const spawnProcess = overrides.spawnProcess ?? spawn;
+ const runSptCommand = overrides.runSptCommand ?? runSpt;
+ const setTimer = overrides.setTimeout ?? globalThis.setTimeout;
+ const clearTimer = overrides.clearTimeout ?? globalThis.clearTimeout;
+ const restartDelaysMs = [...(overrides.restartDelaysMs ?? [250, 1000, 4000])];
+ const env = overrides.env ?? process.env;
- async function setState(state) {
- if (!sid) return;
- const auth = token ? ["--token", token] : ["--session-id", sid];
- await runSpt(["api", "--adapter", ADAPTER, "state", state, id, ...auth]);
- }
+ // [impl->REQ-OMP-NATIVE-TUI]
+ return function ompSpt(pi) {
+ const id = env.SPT_ENDPOINT_ID?.trim();
+ if (!id) return;
+
+ let sid;
+ let token;
+ let listener;
+ let listenerBuffer = "";
+ let listenerRestartCount = 0;
+ let restartTimer;
+ let dispatchTimer;
+ let agentActive = false;
+ let dispatching = false;
+ let current;
+ let stopping = false;
+ let ui;
+ let runtimeCtx;
+ let endpointState;
+ let stateOperation = Promise.resolve();
+ let endPromise;
+ let fatalPromise;
+ const queue = [];
+
+ const logError = (message, error) => {
+ pi.logger.error(message, { error: errorSummary(error) });
+ ui?.notify(`${message}: ${errorSummary(error)}`, "error");
+ };
+
+ 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;
+ await runSptCommand(["api", "--adapter", ADAPTER, "state", state, id, ...authArgs()]);
+ endpointState = state;
+ });
+ stateOperation = operation;
+ return operation;
+ }
- function dispatchNext() {
- if (stopping || agentActive || current || queue.length === 0) return;
- current = queue.shift();
- try {
- pi.sendUserMessage(current.body);
- } catch (error) {
- logError("omp-spt could not submit an inbound message", error);
+ function endSession() {
+ if (!sid || !token) return Promise.resolve();
+ if (!endPromise) {
+ endPromise = (async () => {
+ await stateOperation.catch(() => {});
+ await runSptCommand(["api", "--adapter", ADAPTER, "session-end", id, ...authArgs()]);
+ endpointState = undefined;
+ })();
+ }
+ return endPromise;
+ }
+
+ async function settleItem(item, payload) {
+ if (!item || item.settled) return;
+ item.settled = true;
+ if (!item.from) {
+ logError("omp-spt received a message without a sender", new Error("missing EVENT from attribute"));
+ return;
+ }
+ try {
+ await runSptCommand(["send", item.from, "--from", id], payload);
+ } catch (error) {
+ logError(`omp-spt could not send the outcome to ${item.from}`, error);
+ }
+ }
+
+ function stopResources() {
+ if (dispatchTimer !== undefined) {
+ clearTimer(dispatchTimer);
+ dispatchTimer = undefined;
+ }
+ if (restartTimer !== undefined) {
+ clearTimer(restartTimer);
+ restartTimer = undefined;
+ }
+ const child = listener;
+ listener = undefined;
+ if (child) {
+ try {
+ child.kill();
+ } catch (error) {
+ pi.logger.error("omp-spt could not reap the listener", { error: errorSummary(error) });
+ }
+ }
+ listenerBuffer = "";
+ }
+
+ async function failPending(reason) {
+ const pending = current ? [current, ...queue] : [...queue];
current = undefined;
- setTimeout(dispatchNext, 0);
+ queue.length = 0;
+ for (const item of pending) await settleItem(item, failureMessage(reason));
}
- }
- function startListener() {
- const args = ["ready", id];
- if (process.env.OMP_SPT_SUBNET) args.push("--subnet", process.env.OMP_SPT_SUBNET);
- listener = spawn(process.env.OMP_SPT_SPT_BIN || "spt", args, {
- stdio: ["ignore", "pipe", "pipe"],
- windowsHide: true,
- });
- listener.stdout.setEncoding("utf8");
- listener.stderr.setEncoding("utf8");
- listener.stdout.on("data", (chunk) => {
- listenerBuffer += chunk;
- const drained = drainEvents(listenerBuffer);
- listenerBuffer = drained.rest;
- for (const event of drained.events) queue.push(event);
- dispatchNext();
- });
- listener.stderr.on("data", (chunk) => pi.logger.debug("omp-spt listener", { output: chunk.trim() }));
- listener.on("error", (error) => logError("omp-spt listener failed", error));
- listener.on("close", (code) => {
+ async function failClosed(message, error) {
+ if (fatalPromise) return fatalPromise;
+ fatalPromise = (async () => {
+ stopping = true;
+ stopResources();
+ ui?.setStatus("omp-spt", "spt failed");
+ logError(message, error);
+ await failPending("endpoint stopped before your message could complete");
+ try {
+ await endSession();
+ } catch (endError) {
+ logError("omp-spt session teardown failed", endError);
+ }
+ runtimeCtx?.shutdown();
+ })();
+ return fatalPromise;
+ }
+
+ function scheduleDispatch() {
+ if (
+ stopping ||
+ agentActive ||
+ dispatching ||
+ current ||
+ queue.length === 0 ||
+ dispatchTimer !== undefined
+ ) {
+ return;
+ }
+ dispatchTimer = setTimer(() => {
+ dispatchTimer = undefined;
+ void dispatchNext().catch((error) => failClosed("omp-spt dispatch failed", error));
+ }, 0);
+ dispatchTimer?.unref?.();
+ }
+
+ async function rejectItem(item, reason, error) {
+ logError(`omp-spt ${reason}`, error);
+ await settleItem(item, failureMessage(reason, error));
+ if (current === item) current = undefined;
if (!stopping) {
- ui?.setStatus("omp-spt", "spt offline");
- ui?.notify(`omp-spt listener exited (${code})`, "error");
+ try {
+ await setState("idle");
+ } catch (stateError) {
+ logError("omp-spt could not mark the endpoint idle after a failed submission", stateError);
+ }
}
- });
- }
+ }
- pi.on("session_start", async (_event, ctx) => {
- ui = ctx.ui;
- sid = ctx.sessionManager.getSessionId();
- try {
- const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid];
- if (process.env.OMP_SPT_SUBNET) bindArgs.push("--subnet", process.env.OMP_SPT_SUBNET);
- const bound = await runSpt(bindArgs);
- token = bound.match(/\btoken=([^\s]+)/)?.[1];
- await setState("idle");
- startListener();
- ctx.ui.setStatus("omp-spt", `spt:${id}`);
- } catch (error) {
- ctx.ui.setStatus("omp-spt", "spt bind failed");
- logError(`omp-spt could not bind ${id}`, error);
+ async function dispatchNext() {
+ if (stopping || agentActive || dispatching || current || queue.length === 0) return;
+ dispatching = true;
+ const item = queue.shift();
+ current = item;
+ try {
+ try {
+ await setState("busy");
+ } catch (error) {
+ await rejectItem(item, "could not accept your message", error);
+ return;
+ }
+ if (stopping) return;
+ if (agentActive) {
+ if (current === item) current = undefined;
+ queue.unshift(item);
+ return;
+ }
+ item.stub = senderStub(item.from ?? "unknown");
+ item.submitted = true;
+ 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();
+ }
}
- });
- pi.on("agent_start", async () => {
- agentActive = true;
- try {
- await setState("busy");
- } catch (error) {
- logError("omp-spt could not mark the endpoint busy", error);
+ function handleListenerDeath(reason) {
+ listenerBuffer = "";
+ 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];
+ 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");
+ restartTimer = setTimer(() => {
+ restartTimer = undefined;
+ startListener();
+ }, delay);
+ restartTimer?.unref?.();
}
- });
- pi.on("agent_end", async (event) => {
- agentActive = false;
- const completed = current;
- current = undefined;
- if (completed?.from) {
- const reply = extractReply(event.messages) || "[omp-spt] turn ended without an assistant response.";
+ function startListener() {
+ if (stopping) return;
+ const args = ["ready", id];
+ if (env.OMP_SPT_SUBNET) args.push("--subnet", env.OMP_SPT_SUBNET);
+ let child;
try {
- await runSpt(["send", completed.from, "--from", id], reply);
+ child = spawnProcess(env.OMP_SPT_SPT_BIN || "spt", args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ });
} catch (error) {
- logError(`omp-spt could not reply to ${completed.from}`, error);
+ handleListenerDeath(error);
+ return;
}
+ listener = child;
+ listenerBuffer = "";
+ let dead = false;
+ const died = (reason) => {
+ if (dead) return;
+ dead = true;
+ if (listener === child) listener = undefined;
+ handleListenerDeath(reason);
+ };
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ if (listener !== child || stopping) return;
+ listenerRestartCount = 0;
+ listenerBuffer += chunk;
+ const drained = drainEvents(listenerBuffer);
+ listenerBuffer = drained.rest;
+ for (const event of drained.events) {
+ if (!event.from) {
+ logError(
+ "omp-spt received a message without a sender",
+ new Error("missing EVENT from attribute"),
+ );
+ continue;
+ }
+ queue.push(event);
+ }
+ if (!agentActive && !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));
+ child.on("close", (code) => died(new Error(`spt ready exited ${code}`)));
+ ui?.setStatus("omp-spt", `spt:${id}`);
}
- try {
- await setState("idle");
- } catch (error) {
- logError("omp-spt could not mark the endpoint idle", error);
- }
- setTimeout(dispatchNext, 0);
- });
- pi.on("session_shutdown", async () => {
- stopping = true;
- listener?.kill();
- ui?.setStatus("omp-spt", undefined);
- if (!sid) return;
- const auth = token ? ["--token", token] : ["--session-id", sid];
- try {
- await runSpt(["api", "--adapter", ADAPTER, "session-end", id, ...auth]);
- } catch (error) {
- pi.logger.error("omp-spt session teardown failed", { error: String(error) });
- }
- });
+ pi.on("session_start", async (_event, ctx) => {
+ runtimeCtx = ctx;
+ ui = ctx.ui;
+ sid = ctx.sessionManager.getSessionId();
+ try {
+ const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid];
+ if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET);
+ const bound = await runSptCommand(bindArgs);
+ token = bound.match(/\btoken=([^\s]+)/)?.[1];
+ if (!token) throw new Error("spt bind response did not include token=");
+ await setState("idle");
+ ui.setStatus("omp-spt", `spt:${id}`);
+ startListener();
+ } catch (error) {
+ ui.setStatus("omp-spt", "spt bind failed");
+ await failClosed(`omp-spt could not bind ${id}`, error);
+ }
+ });
+
+ pi.on("session_before_switch", (event, ctx) => {
+ if (stopping) return;
+ ctx.ui.notify(
+ `omp-spt blocked the in-TUI ${event.reason} session switch; end this SPT session first`,
+ "warning",
+ );
+ return { cancel: true };
+ });
+
+ pi.on("context", (event) => {
+ if (!current?.submitted || current.settled) return;
+ const messages = injectEnvelope(event.messages, current);
+ if (messages !== event.messages) return { messages };
+ });
+
+ pi.on("agent_start", async () => {
+ if (stopping) return;
+ agentActive = true;
+ try {
+ await setState("busy");
+ } catch (error) {
+ logError("omp-spt could not mark the endpoint busy", error);
+ }
+ });
+
+ pi.on("agent_end", async (event) => {
+ agentActive = false;
+ if (stopping) return;
+ const completed = current;
+ if (completed?.submitted && !completed.settled) {
+ const reply = extractReply(event.messages, completed.stub);
+ await settleItem(
+ completed,
+ reply || failureMessage("turn ended without an assistant response"),
+ );
+ }
+ if (current === completed) current = undefined;
+ try {
+ await setState("idle");
+ } catch (error) {
+ logError("omp-spt could not mark the endpoint idle", error);
+ }
+ scheduleDispatch();
+ });
+
+ pi.on("session_shutdown", async (_event, ctx) => {
+ stopping = true;
+ runtimeCtx ??= ctx;
+ stopResources();
+ ui?.setStatus("omp-spt", undefined);
+ await failPending("OMP session shut down before your message could complete");
+ try {
+ await endSession();
+ } catch (error) {
+ pi.logger.error("omp-spt session teardown failed", { error: errorSummary(error) });
+ }
+ });
+ };
}
+
+export default createOmpSpt();