warning: in the working copy of 'tools/omp-spt/Cargo.lock', LF will be replaced by CRLF the next time Git touches it diff --git a/CHANGELOG.md b/CHANGELOG.md index c7bf989..051a863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,86 +1,93 @@ # Changelog All notable user-facing changes to **omp-spt** (the Spacetime adapter for oh-my-pi). The format follows [Keep a Changelog](https://keepachangelog.com/); each release section is the public release body verbatim. > Each release below is keyed to the **adapter version of truth** (the version `spt adapter list` reports and the GitHub release tag). omp-spt starts its own version line at 0.1.0; the sections from [0.17.3] down are the inherited **claude-spt** lineage this project forked from, retained for history. +## [0.3.5] - 2026-07-19 + +> Requires spt-core **v0.31.0 or newer** and Oh My Pi **v16.3.15 or newer**. Update with `spt adapter update omp-spt`, then restart existing endpoints so they load the corrected extension. + +### Fixed +- **Assistant output now stays inside the local OMP conversation.** Receiving a peer message no longer forwards subsequent assistant prose, errors, or shutdown text back to that peer. Outbound messages occur only when the agent or operator explicitly uses `spt send` or the `@<…@>` peer-message shortform, restoring `claude-spt` behavioral parity. + ## [0.3.4] - 2026-07-16 > Requires spt-core **v0.31.0 or newer** and Oh My Pi **v16.3.15 or newer**. Update with `spt adapter update omp-spt`, then restart existing endpoints so they load the new extension and skills. ### Added - **An ordinary OMP session can now become a ready or live SPT endpoint in place.** Native `/ready` and `/live` commands select or create endpoint identity, while `/live --auto` offers the most-recent compatible live identity for explicit confirmation; the extension owns the listener, so the agent never has to run a background poll. - **Live endpoints stay reachable during long turns and after interrupted work.** Accepted peer messages enter at the next safe tool or model boundary, retain ordered custody when no boundary appears, and restore honest receivability after cancellation, interruption, or failure. - **OMP now ships native commune, signoff, role, and setup skills.** Commune includes an OMP-native checkpoint mode that saves continuity, compacts context, and wakes the same endpoint; setup diagnoses versions, activation, GitHub transport, and subnet onboarding. - **Activated sessions now teach the SPT surface directly.** Startup guidance, targeted hints, and compatible-update notices cover identity, roster, messaging, lifecycle, continuity, versions, and subnet operations without duplicating public `spt` commands as skills. - **Assistant output can send peer messages with `@`.** The extension dispatches only the explicit shortform, excludes code blocks and ordinary mentions, and reports each target's delivery result. - **The release now includes a static Linux x86-64 musl helper compatibility tier.** Windows MSVC, GNU Linux, and static-musl Linux helpers ship together in one deterministic `adapter.spt`. ### Fixed - **Hosted messaging no longer tears down the listener when a peer reply mentions `` literally.** The frame parser tolerates non-frame `` text inside a message while still rejecting a genuinely nested valid frame, and decodes both published `
` and legacy `
` newline forms. ### Changed - **Release truth is now mechanically enforced.** The manifest version drives helper, plugin, changelog, and public-doc versions; deterministic documentation drift checks gate GitHub Pages publication; each supported target has schema-validated, digest-bound native acceptance evidence. ## [0.2.1] - 2026-07-15 > Requires spt-core **v0.31.0 or newer** for identity-preserving listener authentication. Run `spt update`, then `spt adapter update omp-spt`, and restart each endpoint. ### Fixed - **Starting or resuming an endpoint now records exactly one OMP session in endpoint history.** The listener continues from the already-bound native OMP session instead of registering a second synthetic `boot` session, so resume selection and session metadata remain tied to the real OMP transcript. ## [0.2.0] - 2026-07-14 > Native OMP cutover. Requires spt-core **v0.29.0 or newer** and Oh My Pi **v16.3.15 or newer**. If OMP is older, run `omp update` before launching an endpoint. Update the adapter with `spt adapter update omp-spt`, then restart each endpoint so it loads the packaged extension and helper from this release. ### Added - **Every endpoint now remains a real, attachable OMP session.** Ready and live endpoints use the same native OMP launch path, so an unattended endpoint can still be attached later without losing OMP's normal terminal interface. - **Inbound peer messages now arrive as ordinary OMP turns with full SPT context.** The visible prompt is a safe sender stub, while the complete event envelope is supplied to that turn as context; peer text cannot accidentally invoke a slash command. - **OMP profile-aware session tools.** History, digest, echo-commune, and Psyche session lookup now follow OMP's profile and configuration precedence, including deterministic exact-id and prefix handling. ### Changed - **The adapter is now OMP-native only.** The inherited Claude Code plugin, hooks, CCS profiles, worker topology, headless RPC bridge, and message-idle translation path are removed. `omp-spt` advertises ReadyAgent and LiveAgent only; Worker remains an intentional capability opt-out. - **Fresh, resumed, and daemon-driven turns validate the real Oh My Pi executable before launch.** Explicit overrides, known install paths, and PATH candidates all receive the same identity probe, so an unrelated `omp` command cannot become the hosted terminal. - **One endpoint stays bound to one OMP session for its lifetime.** In-TUI new, resume, fork, handoff, and branch actions are blocked; stop and relaunch the endpoint to select another native session. - **Commune and signoff drops now live under the project-local `.spt/` directory.** The old harness-specific continuity path is no longer used. ### Fixed - **Accepted messages cannot disappear after an outbound reply failure.** Correlated outcomes retry within a finite budget; exhaustion shuts the endpoint down loudly instead of marking undelivered custody as settled. - **Bind, activity, and shutdown races no longer advertise the wrong endpoint state or leak a late listener.** Busy-before-bind is preserved, shutdown-before-bind tears down immediately, and concurrent fatal and lifecycle shutdowns share one bounded session-end sequence. - **Listener recovery now distinguishes crash loops from isolated failures.** Immediate repeated exits still fail closed, while a listener that remains stable resets its consecutive-failure budget. - **Echo-commune handles Unicode transcript boundaries safely.** A byte cap that lands inside a multi-byte character no longer crashes the summarizer. ## [0.1.3] - 2026-07-13 ### Fixed - **Attached endpoints now show OMP's full native TUI.** Native `omp` is the broker PTY process, with SPT messaging supplied by a packaged OMP extension. The previous headless `omp --mode rpc` bridge could only display bridge logs, regardless of whether the endpoint command attached to its PTY. ## [0.1.2] - 2026-07-10 > Liaison bugfix rollup for the two Reavo-reported omp-spt v0.1.1 issues ([#2](https://github.com/BigscreenVR/omp-spt/issues/2), [#3](https://github.com/BigscreenVR/omp-spt/issues/3)). Requires spt-core **v0.27.0 or newer** (unchanged). Update: `spt adapter update omp-spt`, then restart omp endpoints. ### Fixed - **The fresh-endpoint update guidance now keeps the harness terminal attached.** The update notice no longer tells operators to use `--start` for a new Librarian endpoint; the documented command uses attach-default `spt endpoint run --adapter omp-spt --id --create`, so the bringup log (`bridge starting`, `omp ready`, `bound`, `listening`, `READY`) is visible instead of launching silently in the background. - **Adapter updates now use the authenticated GitHub CLI transport.** The repository is currently public, but the release fetch path is now auth-capable via `gh`, so a private or visibility-flipped repo can still discover and download `adapter.spt` through the user's existing GitHub authentication. ## [0.1.1] - 2026-07-09 > Hardening release, driven by a maintainer field report of a wedged endpoint (thanks doyle — [#1](https://github.com/BigscreenVR/omp-spt/pull/1)). Requires spt-core **v0.27.0 or newer** (unchanged). Update: `spt adapter update omp-spt`, then restart your omp endpoints. ### Changed - **The endpoint process is now named `omp-spt`** (was `claude-spt`), so a task list tells you at a glance which adapter a process belongs to — no more misdiagnosing an omp endpoint as a Claude Code one. ### Fixed - **Stopping an endpoint no longer leaves orphans.** The bridge now ties its omp and listener children to its own lifetime at the OS level: however the bridge dies — a stop, a crash, a hard kill — the whole tree goes down with it, and the perch is unregistered on the way out instead of advertising a dead endpoint as listening. - **A silent endpoint is now a diagnosable endpoint.** The attached terminal shows live operational output by default: bringup steps, one line per message in and reply out, every delivery/registration failure, and a periodic idle heartbeat — a working, idle, and broken endpoint each look different now. - **A hung bringup or turn can no longer wedge the perch forever.** Startup is bounded (a wrong or stalled `omp` fails loudly before the endpoint registers, so a failed bringup can't trap the endpoint on this adapter), and each turn has a deadline — on breach the perch recovers to idle and the sender is told the turn failed instead of receiving silence or an empty reply. - **A dead omp process is noticed immediately** (and the endpoint shuts down cleanly), not on the next message. ## [0.1.0] - 2026-07-09 > First release of **omp-spt**: host an [oh-my-pi (omp)](https://github.com/can1357/oh-my-pi) coding agent as a reachable spt endpoint. Requires spt-core **v0.27.0 or newer** and an installed `omp` CLI. Install: `spt adapter add --release BigscreenVR/omp-spt`. ### Added - **Host omp as an spt endpoint.** `spt endpoint run --adapter omp-spt --id ` brings up a reachable omp agent: a message sent to its perch becomes an omp turn, and the answer goes back to the sender. Ask from anywhere on the subnet with `spt ring `. - **Conversation memory across messages.** The endpoint holds one omp session for its lifetime, so follow-ups continue the same conversation; resuming an endpoint reloads its prior session. - **Session digest.** `spt endpoint digest ` shows the endpoint's conversation as it happens — prompts, replies, and the tools omp used. diff --git a/SCOPE.md b/SCOPE.md index 28b6f0e..e662a4e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -1,108 +1,108 @@ # omp-spt scope Status: **locked** (2026-07-14) ## Product `omp-spt` is the OMP-native SPT harness adapter. It starts or resumes a native Oh My Pi TUI inside an spt-core broker PTY and loads a packaged OMP extension that owns messaging and lifecycle integration. The public surface is deliberately narrow: - adapter id and binary: `omp-spt`; - release repository: `BigscreenVR/omp-spt`; - host application: Oh My Pi (`omp`); - endpoint types: `ReadyAgent` and `LiveAgent`; - distribution: one multi-platform `adapter.spt`; - updates: the manifest's `gh_release` avenue. ## In scope 1. **Native hosted sessions** - Fresh and native-resume launch through a validating shim. - OMP remains the terminal owner and renders the attachable TUI. - The extension binds the real OMP session id to the requested endpoint. 2. **Extension-owned delivery** - One serialized custody queue. - Message stub plus the full SPT event context in the same OMP turn. - - Correlated reply or explicit failure for every accepted delivery. + - Assistant output remains local; outbound peer messaging requires explicit CLI or shortform use. - Honest busy/idle state. - Bounded listener restart followed by loud session teardown and OMP shutdown if delivery cannot recover. 3. **Immutable identity** - One OMP session per endpoint lifetime. - In-TUI session switching, branching, creation, and resume are blocked. - A different session requires endpoint stop plus explicit relaunch. 4. **Live continuity** - ReadyAgent and LiveAgent only. - Bounded, per-event OMP Psyche turns; no resident adapter-side Psyche loop. - OMP history, digest, and echo-commune implementations. - Commune and signoff drops under project-local `.spt/`. 5. **Public-contract verification** - Published manifest schema and public `spt api`/CLI surface only. - Minimum spt-core version `0.31.0`, required for identity-preserving listener authentication; v0.29.0 remains the relative continuity-path floor. - Deterministic unit/integration checks plus a same-node native endpoint release gate. 6. **Capability parity** - Extension-native ready/live activation for already-open OMP sessions, including explicit live auto-resume and extension-owned listener custody. - Safe-boundary delivery during active turns and automatic receivability restoration after abnormal turn termination. - Startup briefs, targeted hints, compatible-update notices, packaged commune/signoff/role/setup skills, native checkpoint continuity, and the cross-harness peer-message shortform. - OMP-native provider/profile routing satisfies alternate-routing parity. - Windows x86-64, GNU Linux x86-64, and static Linux x86-64 musl helper targets, each with durable machine-readable release evidence. ## Out of scope - A headless RPC bridge or any second endpoint topology. - Any foreign-harness plugin, hook, context-injection, command launcher, update reconciliation, or model-wrapper compatibility layer. - Worker endpoint advertisement for OMP subagents. - Migration of inherited `.spt` predecessor continuity. - Switching the bound OMP session inside a running endpoint. - Cross-node adapter tests; subnet transport is an spt-core responsibility. - An adapter-owned copy of spt-core state, transport, spool, or auth logic. ## Skill policy The public OMP `omp-plugins` provider is the canonical delivery seam for model-driven adapter capabilities packaged beside the loaded extension. The adapter will ship OMP-native skills for commune (including checkpoint mode), signoff, durable role, and the agent-driven half of setup; release verification must prove discovery from the installed `adapter.spt` layout. Deterministic lifecycle infrastructure remains extension-owned: ready/live activation, binding, listener custody, delivery, activity, and shutdown are not delegated to model-executed skills. ## Release boundary A release must prove fresh launch, attachability, local delivery and correlated reply, lifecycle state, native resume, blocked in-TUI switching, graceful shutdown, listener fail-closed behavior, and both endpoint types. It does not re-prove spt-core's subnet transport. ## Decision index | Decision | Record | Status | |---|---|---| | OMP-only clean cutover | ADR-0008 | Locked | | Native OMP for every endpoint | ADR-0009 | Locked | | Delivery self-heals or closes | ADR-0010 | Locked | | Endpoint/session binding is immutable | ADR-0011 | Locked | | Continuity lives under `.spt/` | ADR-0012 | Locked | | Release gate stops at adapter boundary | ADR-0013 | Locked | | Extension-owned session activation | ADR-0015 | Locked | | Agent capabilities split by native seam | ADR-0016 | Locked | | Active-turn delivery uses safe boundaries | ADR-0017 | Locked | | Checkpoint resets context natively | ADR-0018 | Locked | diff --git a/adapter/omp-spt.toml b/adapter/omp-spt.toml index e783cf5..6dee68f 100644 --- a/adapter/omp-spt.toml +++ b/adapter/omp-spt.toml @@ -1,92 +1,92 @@ # 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.4" +version = "0.3.5" # 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`. """ [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. [session.self] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] command = "{adapter_dir}/omp-spt launch-omp --id {id} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id"] # Resume refreshes the same endpoint snapshot, then uses OMP's native session # selector with the packaged extension. [session.resume] # [impl->REQ-OMP-EXECUTABLE-RESOLUTION] command = "{adapter_dir}/omp-spt launch-omp --id {id} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs" keys = ["id", "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. diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index c051d53..9d2a03c 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -53,244 +53,210 @@ function findValidNestedEvent(raw, bodyStart, close) { candidate = raw.indexOf("", start); if (openEnd < 0) { if (raw.length - start > maxFrameChars) { return { error: protocolError(`EVENT frame exceeded ${maxFrameChars} characters`), events, rest: raw.slice(start), }; } return { events, rest: raw.slice(start) }; } if (openEnd + 1 - start > maxFrameChars) { return { error: protocolError(`EVENT frame exceeded ${maxFrameChars} characters`), events, rest: raw.slice(start), }; } const close = raw.indexOf("
", openEnd + 1); const nested = findValidNestedEvent(raw, openEnd + 1, close); if (nested >= 0) { return { error: protocolError("nested EVENT before closing the current frame"), events, rest: raw.slice(start), }; } if (close < 0) { if (raw.length - start > maxFrameChars) { return { error: protocolError(`EVENT frame exceeded ${maxFrameChars} characters`), events, rest: raw.slice(start), }; } return { events, rest: raw.slice(start) }; } const end = close + "
".length; if (end - start > maxFrameChars) { return { error: protocolError(`EVENT frame exceeded ${maxFrameChars} characters`), events, rest: raw.slice(start), }; } const parsed = parseEventTag(raw.slice(start, openEnd)); if (parsed.error) return { error: parsed.error, events, rest: raw.slice(start) }; if (parsed.attributes.type === "msg") { events.push({ from: parsed.attributes.from, body: decodeBody(raw.slice(openEnd + 1, close)), envelope: raw.slice(start, end), }); } cursor = end; if (events.length >= maxEvents) return { events, rest: raw.slice(cursor) }; } } 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 assistantMessageIdentity(message) { if (message?.role !== "assistant") return undefined; if (typeof message.responseId === "string" && message.responseId) { return `response:${message.provider ?? ""}:${message.model ?? ""}:${message.responseId}`; } if (Number.isFinite(message.timestamp)) { return `timestamp:${message.provider ?? ""}:${message.model ?? ""}:${message.timestamp}`; } 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 unsuccessfulAssistantPayload(message) { - if (!message) return failureMessage("turn ended without an assistant response"); - if (message.stopReason === "aborted") { - return failureMessage("assistant turn was aborted before completion", message.errorMessage); - } - if (message.stopReason === "error") { - return failureMessage("assistant turn failed before completion", message.errorMessage); - } - return failureMessage( - `assistant turn ended without a successful completion (${message.stopReason ?? "missing 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 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 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) { cursor += runLength - 1; continue; } for (let index = cursor; index < closing + runLength; index += 1) { if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; } cursor = closing + runLength - 1; } return masked.join(""); @@ -586,163 +552,160 @@ export function runSpt(args, input, overrides = {}) { void terminateAndReject(new Error(`${label} ${status}${suffix}`)); } }; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); child.stdout.on("data", onStdout); child.stderr.on("data", onStderr); child.on("error", onStdinError); child.on("close", onClose); if (input !== undefined) { child.stdin.on("error", onStdinError); child.stdin.once("finish", onStdinFinish); } signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { onAbort(); return; } timeoutTimer = setTimer(() => { timeoutTimer = undefined; void terminateAndReject(new Error(`${label} timed out after ${timeoutMs}ms`)); }, timeoutMs); timeoutTimer?.unref?.(); if (input !== undefined) { try { child.stdin.end(input); } catch (error) { void terminateAndReject(error); } } }); } export function createOmpSpt(overrides = {}) { const spawnProcess = overrides.spawnProcess ?? spawn; const setTimer = overrides.setTimeout ?? globalThis.setTimeout; const clearTimer = overrides.clearTimeout ?? globalThis.clearTimeout; const env = overrides.env ?? process.env; const platform = overrides.platform ?? process.platform; const killGraceMs = overrides.killGraceMs ?? DEFAULT_KILL_GRACE_MS; const killForceMs = overrides.killForceMs ?? DEFAULT_KILL_FORCE_MS; const customRunSptCommand = overrides.runSptCommand; const commandTimeoutMs = overrides.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; const shortformCommandTimeoutMs = overrides.shortformCommandTimeoutMs ?? DEFAULT_SHORTFORM_COMMAND_TIMEOUT_MS; const shortformConcurrency = DEFAULT_SHORTFORM_CONCURRENCY; const runSptCommand = customRunSptCommand ?? ((args, input, options = {}) => runSpt(args, input, { clearTimeout: clearTimer, commandTimeoutMs: options.timeoutMs ?? commandTimeoutMs, env, killForceMs, killGraceMs, setTimeout: setTimer, signal: options.signal, spawnProcess, })); const shutdownBudgetMs = overrides.shutdownBudgetMs ?? DEFAULT_SHUTDOWN_BUDGET_MS; const requestedShutdownCommandTimeoutMs = overrides.shutdownCommandTimeoutMs ?? DEFAULT_SHUTDOWN_COMMAND_TIMEOUT_MS; const terminationWindowMs = killGraceMs + killForceMs; const maxShutdownCommandTimeoutMs = Math.max( 1, Math.floor((shutdownBudgetMs - 3 * terminationWindowMs) / 2), ); const shutdownCommandTimeoutMs = Math.max( 1, Math.min(requestedShutdownCommandTimeoutMs, maxShutdownCommandTimeoutMs), ); const listenerBufferLimit = overrides.listenerBufferLimit ?? DEFAULT_LISTENER_BUFFER_LIMIT; const acceptedQueueLimit = overrides.acceptedQueueLimit ?? DEFAULT_ACCEPTED_QUEUE_LIMIT; const acceptedBytesLimit = overrides.acceptedBytesLimit ?? DEFAULT_ACCEPTED_BYTES_LIMIT; const restartDelaysMs = [...(overrides.restartDelaysMs ?? [250, 1000, 4000])]; - const outcomeRetryDelaysMs = [ - ...(overrides.outcomeRetryDelaysMs ?? [250, 1000, 4000]), - ]; const sessionEndRetryDelaysMs = [ ...(overrides.sessionEndRetryDelaysMs ?? [250, 1000]), ]; const listenerStableMs = overrides.listenerStableMs === false ? undefined : (overrides.listenerStableMs ?? 30_000); const checkUpdates = overrides.checkUpdates ?? true; const updateProbeTimeoutMs = overrides.updateProbeTimeoutMs ?? 1_500; const fetchLatestAdapterVersion = overrides.fetchLatestAdapterVersion ?? (async () => { const controller = new AbortController(); const timer = setTimer(() => controller.abort(), updateProbeTimeoutMs); timer?.unref?.(); try { const response = await fetch( "https://api.github.com/repos/BigscreenVR/omp-spt/releases/latest", { headers: { accept: "application/vnd.github+json" }, signal: controller.signal, }, ); if (!response.ok) return undefined; return (await response.json())?.tag_name; } catch { return undefined; } finally { clearTimer(timer); } }); const checkpointParameters = (pi) => { const z = pi.zod?.z ?? pi.zod; return z.object({ wake: z .string() .optional() .describe("Instruction for the first continuation after native context compaction"), }); }; const endpointIdPattern = /^[A-Za-z0-9_-]+$/; const commandCompletions = (cachedIds, includeAuto) => (prefix) => { const values = includeAuto ? ["--auto", ...cachedIds] : [...cachedIds]; const matches = values .filter((value) => value.startsWith(prefix.trim())) .map((value) => ({ value, label: value })); return matches.length > 0 ? matches : null; }; function normalizePath(value) { let normalized = String(value ?? "").replaceAll("\\", "/"); while ( normalized.endsWith("/") && normalized.length > 1 && !/^[A-Za-z]:\/$/.test(normalized) ) { 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 ( @@ -1361,384 +1324,312 @@ export function createOmpSpt(overrides = {}) { 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; await runCommand(["api", "--adapter", ADAPTER, "state", state, id, ...authArgs()]); endpointState = state; }); stateOperation = operation; return operation; } async function syncDesiredState() { if (!bindPromise) return; await bindPromise; while (!stopping && endpointState !== 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); } } } - // [impl->REQ-OMP-EXTENSION-CUSTODY] - function settleItem(item, payload) { - if (!item) return Promise.resolve(); - if (item.outcomePromise) return item.outcomePromise; - item.settling = true; - item.outcomePromise = (async () => { - if (!item.from) throw new Error("missing EVENT from attribute"); - for (let attempt = 0; ; attempt += 1) { - try { - await runCommand(["send", item.from, "--from", id], payload); - item.settled = true; - return; - } catch (error) { - if (shutdownMode || attempt >= outcomeRetryDelaysMs.length) throw error; - const delay = outcomeRetryDelaysMs[attempt]; - pi.logger.error( - `omp-spt could not send the outcome to ${item.from}; retrying ${ - attempt + 1 - }/${outcomeRetryDelaysMs.length} in ${delay}ms`, - { error: errorSummary(error) }, - ); - await waitForRetry(delay); - } - } - })(); - return item.outcomePromise; - } 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() { if (dispatchTimer !== undefined) { clearTimer(dispatchTimer); dispatchTimer = 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; } } - async function settlePendingItem(item, reason) { - try { - if (item.outcomePromise && !item.settled) { - let existingError; - try { - await item.outcomePromise; - } catch (error) { - existingError = error; - } - if (item.settled) return; - if (existingError && !shutdownMode) { - logError( - `omp-spt could not return custody to ${item.from ?? "unknown"}`, - existingError, - ); - return; - } - item.outcomePromise = undefined; - item.settling = false; - } - try { - await settleItem(item, failureMessage(reason)); - } catch (error) { - logError(`omp-spt could not return custody to ${item.from ?? "unknown"}`, error); - } - } finally { - releaseItem(item); - } - } - - async function failPending(reason) { + function releasePending() { const pending = current ? [current, ...queue] : [...queue]; if (overflowItem) pending.push(overflowItem); current = undefined; queue.length = 0; overflowItem = undefined; - for (let index = 0; index < pending.length; index += 1) { - if (shutdownMode) { - await Promise.all( - pending.slice(index).map((item) => settlePendingItem(item, reason)), - ); - return; - } - await settlePendingItem(pending[index], reason); - } + for (const item of pending) releaseItem(item); } function teardownSession(pendingReason) { if (!teardownPromise) { stopping = true; const operation = (async () => { await stopResources(); - await failPending(pendingReason); + releasePending(); await bindPromise?.catch(() => {}); await endSessionWithRetry(); })(); 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) { if (stopping && shutdownMode) return teardownPromise ?? Promise.resolve(); if (fatalPromise) return fatalPromise; fatalPromise = (async () => { ui?.setStatus("omp-spt", "spt failed"); logError(message, error); try { await teardownSession("endpoint stopped before your message could complete"); } catch (teardownError) { logError("omp-spt session teardown failed", teardownError); } 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) => { 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); - try { - await settleItem(item, failureMessage(reason, error)); - } catch (outcomeError) { - if (stopping) return; - await failClosed(`omp-spt could not send the outcome to ${item.from ?? "unknown"}`, outcomeError); - return; - } 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, ); } } } // [impl->REQ-OMP-EXTENSION-CUSTODY] async function dispatchNext() { if (stopping || agentActive || 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; 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(); } } // [impl->REQ-OMP-LISTENER-FAIL-CLOSED] 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]; 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?.(); } function startListener() { if (stopping) return; const args = ["api", "--adapter", ADAPTER, "listen", id, "--session-id", sid]; @@ -1815,240 +1706,222 @@ export function createOmpSpt(overrides = {}) { ); return; } event.acceptedBytes = eventBytes; event.accounted = true; acceptedBytes += eventBytes; 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, 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", `spt:${id}`); } // [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) => { const baseline = captureAssistantBaseline(event.messages); if (agentActive) { if (!turnContextObserved) { turnAssistantBaseline = baseline; turnContextObserved = true; } } else { observedAssistantBaseline = baseline; } let messages = event.messages; if ( agentActive && !current && !dispatching && queue.length > 0 ) { const item = queue.shift(); item.stub = senderStub(item.from ?? "unknown"); item.submitted = true; item.activeInjected = true; item.assistantBaseline = baseline; current = item; } - if (current?.submitted && !current.settling) { + if (current?.submitted) { if (current.activeInjected) { const content = `${current.stub}\n\n${current.envelope}`; if ( !messages.some( (message) => message?.role === "user" && messageText(message) === content, ) ) { messages = [...messages, { role: "user", content }]; } } else { current.assistantBaseline ??= baseline; messages = injectEnvelope(messages, current); } } 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) => { if (!activated || !id || stopping) return; 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 })), ); const statuses = 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; try { pi.sendMessage( { customType: "omp-spt-peer-status", content: `OMP SPT peer-message results:\n- ${statuses.join("\n- ")}`, display: true, attribution: "user", }, { deliverAs: "nextTurn", triggerTurn: true }, ); } catch (error) { if (!stopping) logError("omp-spt could not inject peer-message results", error); } } // [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function completeTurn(event) { agentActive = false; desiredState = "idle"; if (stopping) return; const messages = event.messages ?? []; - const completed = current; - const completedBaseline = completed?.assistantBaseline ?? turnAssistantBaseline; const currentTurnAssistant = assistantAfterBaseline(messages, turnAssistantBaseline); - const completedAssistant = assistantAfterBaseline(messages, completedBaseline); + const completed = current; const currentTurnReply = successfulAssistant(currentTurnAssistant) ? messageText(currentTurnAssistant) : ""; - if (completed?.submitted && !completed.settled) { - const reply = messageText(completedAssistant); - const payload = successfulAssistant(completedAssistant) - ? reply || failureMessage("turn completed without a textual assistant response") - : unsuccessfulAssistantPayload(completedAssistant); - try { - await settleItem(completed, payload); - } catch (error) { - if (stopping) return; - await failClosed( - `omp-spt could not send the outcome to ${completed.from ?? "unknown"}`, - error, - ); - return; - } - } 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; } await dispatchShortforms(currentTurnReply); if (!stopping) scheduleDispatch(); } pi.on("agent_start", async () => { if (stopping) return; turnCompletionPromise = undefined; turnAssistantBaseline = observedAssistantBaseline; turnContextObserved = false; agentActive = true; desiredState = "busy"; try { await syncDesiredState(); } catch (error) { if (!stopping) await failClosed("omp-spt could not mark the endpoint busy", error); } }); // [impl->REQ-OMP-EXTENSION-CUSTODY] 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(); diff --git a/docs-site/llms-full.txt b/docs-site/llms-full.txt index 180c149..396fcef 100644 --- a/docs-site/llms-full.txt +++ b/docs-site/llms-full.txt @@ -1,158 +1,158 @@ # omp-spt — full documentation > Generated from `docs-site/src/SUMMARY.md` by `ci/docs/build-docs.py`. > Do not edit this file; edit the canonical Markdown pages and regenerate it. --- # Introduction `omp-spt` is the native [Oh My Pi](https://github.com/can1357/oh-my-pi) adapter for [Spacetime (`spt-core`)](https://sabermage.github.io/spt-releases). It gives named Spacetime endpoints a real OMP terminal session rather than translating OMP through another interface. The adapter supports the two hostable agent roles that belong in an interactive OMP session: - **`ReadyAgent`** stays reachable for direct messages in its native OMP TUI. - **`LiveAgent`** uses the same native endpoint and also activates the live-agent Psyche lifecycle. There is no separate plugin installation step. Install OMP and `spt-core`, then install the `BigscreenVR/omp-spt` adapter release with its bundled extension and OMP plugin skills. ## Mental model Three components share the work: ```text spt-core endpoint lifecycle | | launch, resume, attach v native OMP process |-- owns the broker PTY and attachable TUI `-- loads adapter/strings/omp-spt.mjs | `-- bind, delivery, activity state, replies, shutdown ``` - **`spt-core`** owns endpoint records, messaging, lifecycle coordination, subnet reachability, adapter installation, and updates. - **Native OMP** owns the hosted process, interactive terminal, model turn, tools, and transcript. - **`omp-spt`** supplies the launch and transcript helpers plus the packaged OMP extension that connects native OMP events to `spt-core`. The release artifact is one fat `adapter.spt` archive. It contains the supported adapter binaries, manifest, shared native extension, OMP plugin metadata, and packaged commune, signoff, role, and setup skills, so installation and updates move as one unit. ## What the extension does When OMP starts a hosted session, the extension binds the OMP session id to the named Spacetime endpoint and starts message delivery. Incoming messages are queued into OMP turns. OMP's assistant response is sent back to the original sender, and the endpoint moves between busy and idle as the turn runs. When the TUI shuts down, the extension ends the bound session and releases its listener. The result is still normal OMP. You attach to OMP's own TUI, and OMP remains in direct control of the broker PTY. Start with the [Quickstart](./quickstart.md). For event-by-event behavior, read the [Native integration contract](./reference/harness-contract.md). --- # Quickstart Goal: install `omp-spt`, create a named endpoint, and attach to its native OMP TUI. ## 1. Install the prerequisites You need [Oh My Pi 16.3.15 or newer](https://github.com/can1357/oh-my-pi/commit/e8d0a93d) and [`spt-core`](https://sabermage.github.io/spt-releases). There is no separate plugin installation step because the adapter release carries its OMP plugin skills. -The v0.3.4 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. +The v0.3.5 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. Its archive contains Windows MSVC, Linux GNU, and static Linux musl x86_64 helpers, but no macOS or Arm64 payload. The musl helper is a compatibility tier for OMP-capable hosts; it is not a generic Alpine support claim. **PowerShell (x86_64 Windows)** ```powershell if (-not (Get-Command omp -ErrorAction SilentlyContinue)) { irm https://omp.sh/install.ps1 | iex } if (-not (Get-Command spt -ErrorAction SilentlyContinue)) { irm https://sabermage.github.io/spt-releases/install.ps1 | iex } ``` **bash (x86_64 Linux / Git Bash on x86_64 Windows)** ```bash command -v omp >/dev/null 2>&1 || curl -fsSL https://omp.sh/install | sh command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh ``` Open a fresh shell if either installer updated your `PATH`, then verify both commands: ```bash omp --version spt --version ``` `omp-spt` requires Oh My Pi 16.3.15 or newer and `spt-core` 0.31.0 or newer. If either version check reports an older release, run `omp update` or `spt update` as appropriate, open a fresh shell if needed, and repeat both checks before installing the adapter. ## 2. Install the adapter ```bash spt adapter add --release BigscreenVR/omp-spt ``` This installs one fat `adapter.spt` release for the supported x86_64 Windows and Linux targets. The archive includes their adapter binaries, the manifest, native extension, OMP plugin metadata, and packaged commune, signoff, role, and setup skills. No separate plugin installation is needed. ## 3. Create and attach to an endpoint Choose a stable endpoint id. This example uses `librarian`: ```bash spt endpoint run --adapter omp-spt --id librarian --create ``` For another name, use the canonical form `spt endpoint run --adapter omp-spt --id --create` and replace ``. The command launches OMP with `adapter/strings/omp-spt.mjs` and attaches your terminal directly to OMP's native TUI. Native attach is the default. `ReadyAgent` and `LiveAgent` are the supported hostable roles. During bring-up, the extension: 1. binds the OMP session id to the Spacetime endpoint; 2. marks the endpoint idle and starts delivery; 3. submits each incoming message as an OMP turn; 4. sends OMP's response to the original sender; and 5. ends the binding when the native TUI shuts down. You now have a named endpoint in the standard Spacetime messaging and lifecycle model. Use the [`spt-core` documentation](https://sabermage.github.io/spt-releases) for endpoint rosters, messaging, subnets, and other public commands. ## 4. Update later ```bash spt adapter update omp-spt ``` OMP loads the packaged extension fresh on endpoint bring-up. Restart an already running endpoint to move it to the updated adapter: ```bash spt endpoint stop librarian spt endpoint run --adapter omp-spt --id librarian ``` diff --git a/docs-site/src/quickstart.md b/docs-site/src/quickstart.md index b29e860..d6bf4ad 100644 --- a/docs-site/src/quickstart.md +++ b/docs-site/src/quickstart.md @@ -1,92 +1,92 @@ # Quickstart Goal: install `omp-spt`, create a named endpoint, and attach to its native OMP TUI. ## 1. Install the prerequisites You need [Oh My Pi 16.3.15 or newer](https://github.com/can1357/oh-my-pi/commit/e8d0a93d) and [`spt-core`](https://sabermage.github.io/spt-releases). There is no separate plugin installation step because the adapter release carries its OMP plugin skills. -The v0.3.4 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. +The v0.3.5 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. Its archive contains Windows MSVC, Linux GNU, and static Linux musl x86_64 helpers, but no macOS or Arm64 payload. The musl helper is a compatibility tier for OMP-capable hosts; it is not a generic Alpine support claim. **PowerShell (x86_64 Windows)** ```powershell if (-not (Get-Command omp -ErrorAction SilentlyContinue)) { irm https://omp.sh/install.ps1 | iex } if (-not (Get-Command spt -ErrorAction SilentlyContinue)) { irm https://sabermage.github.io/spt-releases/install.ps1 | iex } ``` **bash (x86_64 Linux / Git Bash on x86_64 Windows)** ```bash command -v omp >/dev/null 2>&1 || curl -fsSL https://omp.sh/install | sh command -v spt >/dev/null 2>&1 || curl -fsSL https://sabermage.github.io/spt-releases/install.sh | sh ``` Open a fresh shell if either installer updated your `PATH`, then verify both commands: ```bash omp --version spt --version ``` `omp-spt` requires Oh My Pi 16.3.15 or newer and `spt-core` 0.31.0 or newer. If either version check reports an older release, run `omp update` or `spt update` as appropriate, open a fresh shell if needed, and repeat both checks before installing the adapter. ## 2. Install the adapter ```bash spt adapter add --release BigscreenVR/omp-spt ``` This installs one fat `adapter.spt` release for the supported x86_64 Windows and Linux targets. The archive includes their adapter binaries, the manifest, native extension, OMP plugin metadata, and packaged commune, signoff, role, and setup skills. No separate plugin installation is needed. ## 3. Create and attach to an endpoint Choose a stable endpoint id. This example uses `librarian`: ```bash spt endpoint run --adapter omp-spt --id librarian --create ``` For another name, use the canonical form `spt endpoint run --adapter omp-spt --id --create` and replace ``. The command launches OMP with `adapter/strings/omp-spt.mjs` and attaches your terminal directly to OMP's native TUI. Native attach is the default. `ReadyAgent` and `LiveAgent` are the supported hostable roles. During bring-up, the extension: 1. binds the OMP session id to the Spacetime endpoint; 2. marks the endpoint idle and starts delivery; 3. submits each incoming message as an OMP turn; 4. sends OMP's response to the original sender; and 5. ends the binding when the native TUI shuts down. You now have a named endpoint in the standard Spacetime messaging and lifecycle model. Use the [`spt-core` documentation](https://sabermage.github.io/spt-releases) for endpoint rosters, messaging, subnets, and other public commands. ## 4. Update later ```bash spt adapter update omp-spt ``` OMP loads the packaged extension fresh on endpoint bring-up. Restart an already running endpoint to move it to the updated adapter: ```bash spt endpoint stop librarian spt endpoint run --adapter omp-spt --id librarian ``` diff --git a/docs/CI.md b/docs/CI.md index eeadaac..64a9457 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,117 +1,118 @@ # CI and release acceptance `omp-spt` uses two verification layers: 1. deterministic gates that run on every change; and 2. release acceptance against a real native OMP endpoint. The first layer is repeatable and does not require a live model session. The second proves the user-visible hosting contract that deterministic tests cannot establish. A deterministic pass is necessary, but it is not release acceptance. ## Deterministic gates Run these from the repository root: ```sh sh tests/ci-gates.sh sh ci/run-gates.sh node tests/omp-extension.mjs ``` `tests/ci-gates.sh` guards the gate dispatcher itself. `ci/run-gates.sh` performs shell-syntax checks, validates `adapter/omp-spt.toml` against the vendored published schema, checks the native launch and session manifest, tests the fat archive contract, runs a hermetic release-acquisition dry run with fake `gh`/`spt` commands, and runs the `omp-spt` helper's Rust tests and release build. It also runs `traceable-reqs check` when the command is installed. `tests/omp-extension.mjs` exercises extension delivery, serialized custody, correlated outcomes, busy/idle transitions, listener failure handling, immutable session binding, and shutdown. These are binary pass/fail checks. A release run must treat any `SKIP` caused by a missing interpreter, Rust toolchain, platform binary, or `traceable-reqs` installation as an incomplete gate, provision the dependency, and rerun. The archive test may intentionally prove that the packer refuses missing target binaries, but a release still needs all three target builds before packaging. The live acquisition integration intentionally skips unless `OMP_SPT_ACCEPTANCE=1`; after opt-in, missing release version, target, tools, or GitHub authentication are failures. Its hermetic central-gate test opts in and exercises those fail-closed paths without network access. ## Native OMP release acceptance ADR-0013 sets the release boundary. The supported helper targets are **x86_64 Windows** (`x86_64-pc-windows-msvc`), **x86_64 GNU Linux** (`x86_64-unknown-linux-gnu`), and a static **x86_64 musl Linux** compatibility tier (`x86_64-unknown-linux-musl`). The asset has no macOS or Arm64 payload. Acceptance must use the exact tagged candidate `adapter.spt`, a real `omp` installation, the native packaged extension, and disposable endpoint identities. Complete the full acceptance sequence independently on all three targets before promotion. Do not reuse an operator's long-lived endpoint id. Install the tagged candidate on each acceptance host and verify its manifest version: ```sh spt adapter add --release BigscreenVR/omp-spt --tag vX.Y.Z spt adapter version omp-spt ``` The version command must print exactly `X.Y.Z`. Then record target-specific evidence for every item below; a pass on one target cannot stand in for either of the others. ### musl selected-helper boundary Under [ADR-0019](adr/0019-musl-acceptance-follows-selected-helper.md), the musl record must come from a disposable OMP-capable host where spt-core selects `x86_64-unknown-linux-musl/omp-spt` from the tagged archive and the installed helper digest exactly matches that member. The base distribution may use glibc; helper selection and native execution, not distribution branding, define this adapter seam. Record `target: x86_64-unknown-linux-musl`, native `spt --version` and `omp --version` output, tagged acquisition, and the same endpoint evidence required below. This is a helper compatibility tier, not a claim of generic Alpine support. Manually executing the static helper does not qualify, and neither does a container where real OMP is absent. ### 1. Fresh bringup and attached TUI ```sh spt endpoint run --adapter omp-spt --id omp-spt-accept-fresh --create ``` The default action attaches the terminal. Pass only if the broker-held terminal displays the real interactive OMP TUI, the packaged `adapter/strings/omp-spt.mjs` extension binds `omp-spt-accept-fresh`, and the endpoint becomes reachable. Do not add a background-start flag: the attached native TUI is part of this proof. -### 2. Same-node message, turn, correlated reply, and state +### 2. Same-node message, turn, explicit reply, and state From a second terminal on the same node: ```sh -printf 'Reply with exactly OMP-SPT-ACCEPTED after reading this message.' | +printf 'After reading this message, explicitly send exactly OMP-SPT-ACCEPTED to the sender using spt send or the peer-message shortform.' | spt ring omp-spt-accept-fresh --timeout 120 ``` Pass only if: - the delivery becomes one ordinary OMP user turn containing the sender stub and complete SPT event context; - the TUI visibly runs that turn; -- `spt ring` prints the assistant's reply for that request, proving sender correlation; and -- `spt endpoint list --json` shows the endpoint move from idle to busy for the turn and back to idle after the reply. +- `spt ring` prints `OMP-SPT-ACCEPTED`, proving the agent used an explicit outbound messaging action; +- ordinary assistant prose visible in the TUI is not forwarded to the sender; and +- `spt endpoint list --json` shows the endpoint move from idle to busy for the turn and back to idle after completion. -This is an end-to-end custody check, not a check for model wording alone. A rejection or turn failure must return an explicit failure outcome to the sender rather than silently dropping the request. +This is an end-to-end delivery and explicit-messaging check. Turn failure, submission failure, and shutdown must remain local rather than synthesizing an outbound peer message. ### 3. Immutable in-TUI binding and native resume Record the bound OMP session id. While the endpoint is running, attempt OMP's in-TUI new-session and resume/switch actions. Both must be blocked with an `omp-spt` warning, and the bound session id must remain unchanged. End the endpoint gracefully, then bind a new disposable endpoint explicitly to the recorded OMP session: ```sh spt endpoint shutdown omp-spt-accept-fresh spt endpoint run --adapter omp-spt --id omp-spt-accept-resume --resume ``` Pass only if this launches OMP's native resume path into an attached TUI, preserves the prior transcript, and binds the new endpoint to the requested session. In-TUI switching stays blocked after resume. Native resume happens at endpoint launch, never by moving a running endpoint to another session. ### 4. ReadyAgent and LiveAgent Exercise both hostable roles through spt-core's current ReadyAgent and LiveAgent flows: - **ReadyAgent:** the native OMP endpoint binds, listens, receives a same-node request, replies, and shuts down without requiring a daemon-driven Psyche turn. - **LiveAgent:** the same native OMP hosting path remains attached and reachable while the daemon drives the manifest's bounded `psyche-omp` role; a Psyche event completes successfully, and normal message/turn/reply behavior still works afterward. Pass only if `omp-spt` advertises exactly ReadyAgent and LiveAgent and both use the native OMP endpoint. A Worker or an alternate hosting process is not an accepted adapter role. ### 5. Graceful shutdown ```sh spt endpoint shutdown omp-spt-accept-resume ``` Pass only if the extension ends the SPT session, reaps its listener, clears its TUI status, and the endpoint is no longer advertised as online. There must be no false-online endpoint and no orphaned acceptance process. ## Adapter release boundary Cross-node transport is outside the `omp-spt` release gate. Once the exact message contract succeeds through the adapter on one node, subnet routing adds no OMP-specific seam and belongs to spt-core. A cross-node check may be useful for spt-core qualification, but it must not replace or block the same-node adapter acceptance above. -Release evidence must identify the candidate `vX.Y.Z` tag and asset, OMP and spt-core versions, disposable endpoint ids, fresh and resumed OMP session ids, gate outputs, state transitions, correlated reply, both role outcomes, and final shutdown result separately for `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and a host where spt-core selected the `x86_64-unknown-linux-musl` helper. Each target needs its own machine-readable release record; the musl record remains invalid if it omitted selected-member digest equality or any native endpoint check. +Release evidence must identify the candidate `vX.Y.Z` tag and asset, OMP and spt-core versions, disposable endpoint ids, fresh and resumed OMP session ids, gate outputs, state transitions, explicit messaging reply, both role outcomes, and final shutdown result separately for `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and a host where spt-core selected the `x86_64-unknown-linux-musl` helper. Each target needs its own machine-readable release record; the musl record remains invalid if it omitted selected-member digest equality or any native endpoint check. diff --git a/docs/DOCS-STRATEGY.md b/docs/DOCS-STRATEGY.md index c75f34f..a584732 100644 --- a/docs/DOCS-STRATEGY.md +++ b/docs/DOCS-STRATEGY.md @@ -4,164 +4,164 @@ > under `docs-site/`, mdBook renders it, and GitHub Pages publishes it from the same source of > truth. Planning records, ADRs, and contributor design notes remain separate from the public > learning path. ## Audience and product boundary The documentation serves human operators and development agents integrating with [`BigscreenVR/omp-spt`](https://github.com/BigscreenVR/omp-spt). Author each fact once in clean Markdown, then make it available at two depths: - a navigable, human-rendered site; and - compact agent exports with stable links and the same canonical commands. The documented product is the native Oh My Pi adapter. OMP owns the hosted interactive session and TUI, spt-core owns endpoint orchestration and transport, and the packaged OMP extension joins those public contracts inside the native session. Do not document alternate harness paths under the `omp-spt` identity. ## Principles 1. **Get to a working native endpoint in under ten minutes.** The first tutorial installs the adapter, launches OMP, and reaches the native TUI before explaining internals. 2. **Keep Diátaxis modes separate.** Tutorials teach, how-to guides solve a task, reference states exact contracts, and explanation pages develop the mental model. 3. **Use deterministic, copy-pasteable examples.** Commands use `omp-spt`, `BigscreenVR/omp-spt`, and concrete endpoint IDs such as `atlas`. Avoid placeholder-only examples and hidden prerequisites. 4. **Give one canonical path for each task.** Clearly label historical or alternative material as non-current. Human readers and agents should make the same choice from the same page. 5. **Document public surfaces, not private implementation details.** Link to the published spt-core CLI and manifest reference for core semantics. Explain only the adapter behavior that `omp-spt` owns. 6. **Make reference complete.** Cover commands, manifest fields, extension-owned behavior, lifecycle transitions, failure outcomes, archive contents, and supported release checks. 7. **Write to the reader.** Use second person, active voice, short sentences, and exact nouns. 8. **Explain ownership and invariants.** Readers need to know why OMP keeps the TUI, why one endpoint keeps one session, and where spt-core takes over. 9. **Keep anchors and URLs stable.** Redirect renamed pages and preserve durable section IDs when their contracts still exist. 10. **Gate documentation drift in CI.** Generated reference, agent exports, CLI snapshots, and the mdBook build must be reproducible and checked against committed output. ## Information architecture Order the public site by the user's journey. Put a runnable success first, then concepts in dependency order, then complete reference. ### 1. Start here - **Introduction:** what `omp-spt` does and the OMP / spt-core ownership boundary. - **Requirements:** supported spt-core floor, supported hosts, and an installed native OMP. - **Quickstart:** install the release adapter, create endpoint `atlas`, and attach to its native TUI. ### 2. Install and update - Install from the `BigscreenVR/omp-spt` GitHub release. - Explain that the default release asset is one multi-platform `adapter.spt`. - Update the registered adapter and restart an endpoint when it must pick up the new hosting payload. - Troubleshoot registration, version-floor, archive-selection, and executable-resolution errors. ### 3. Run native OMP endpoints - Create and launch a fresh endpoint. - Attach to, detach from, stop, and relaunch the broker-held native TUI. - Resume an existing OMP session through endpoint launch. - Explain the immutable binding: one SPT endpoint owns one OMP session for its lifetime, so a different session requires a stop and explicit relaunch. ### 4. Use hostable roles - Describe `ReadyAgent` and `LiveAgent` as the only hostable roles. - Show how both roles use the same native OMP endpoint. - Explain the daemon-driven Psyche behavior that distinguishes `LiveAgent`. - State why OMP subagents are not advertised as separately hostable endpoints. ### 5. Message an endpoint - Use the public spt-core send and roster surfaces. -- Trace one inbound message through serialized extension delivery, one OMP turn, and the - correlated reply to its sender. -- Document busy and idle activity, queued delivery, explicit failure replies, listener recovery, - fail-closed shutdown, and graceful session end. +- Trace one inbound message through serialized extension delivery and one OMP turn. +- Show that assistant output remains local and that replies require an explicit CLI or shortform action. +- Document busy and idle activity, queued delivery, listener recovery, fail-closed shutdown, and + graceful session end. ### 6. Preserve continuity and inspect records - Explain commune and signoff drops in project-local `.spt/`. - Document endpoint-qualified continuity filenames without treating them as OMP configuration. - Show the difference between opaque single-session history and the bounded digest view. - Keep resume, continuity, history, and digest examples tied to the same immutable session ID. ### 7. Adapter and release reference - Manifest reference for `[adapter]`, `[update]`, `[identity]`, `[session]`, `[history]`, `[digest]`, environment injection, and shared strings. - Extension reference for bind, message custody, context injection, correlated replies, activity, session-switch refusal, recovery, and shutdown. - `adapter.spt` reference for the root manifest, `strings/omp-spt.mjs`, and supported host binaries. - Release verification checklist and the explicit adapter / spt-core boundary. Each major capability can use the same internal sequence when it needs more than one page: `Overview -> Tutorial -> How-to -> Reference -> Explanation`. Do not force all five page types when one concise page is clearer. ## Canonical quickstart examples Install the adapter from its release repository: ```bash spt adapter add --release BigscreenVR/omp-spt ``` Create and launch a native endpoint: ```bash spt endpoint run --adapter omp-spt --id atlas --create ``` Attach to the same native OMP TUI from another terminal: ```bash spt rc atlas ``` Update the installed adapter: ```bash spt adapter update omp-spt ``` These are the canonical names and command shapes. Endpoint launch uses native attach behavior; examples must not introduce a separate start switch. A native-resume tutorial must obtain a real OMP session ID in a preceding step and use it consistently for launch, history, digest, and continuity examples. Do not publish a fake reusable session ID that can never resolve on the reader's machine. ## Native extension documentation Describe `adapter/strings/omp-spt.mjs` by observable responsibility: 1. OMP starts natively in the terminal held by spt-core. 2. The extension binds the OMP session to the endpoint ID supplied by spt-core. 3. It receives SPT events, serializes them, and submits each through OMP's message API. 4. It preserves the full event envelope as context and sends the completed assistant response back to that event's sender. 5. It reports busy and idle state, blocks in-TUI session changes, retries listener loss within a bounded budget, and ends the SPT session on shutdown or unrecoverable delivery loss. Keep network routing, subnet transport, terminal custody, and adapter registry behavior in the spt-core ownership column. Keep model turns, transcript identity, and TUI behavior in the OMP ownership column. The extension page explains only the seam between them. ## Release artifact documentation `adapter.spt` is the only public adapter release artifact. Document it as a fat archive that contains: - `manifest.toml` at the archive root; - shared `strings/omp-spt.mjs`; and - the `omp-spt` binary for each supported host target. diff --git a/docs/KNOWN-HAZARDS.md b/docs/KNOWN-HAZARDS.md index 0e468b0..a3df1f8 100644 --- a/docs/KNOWN-HAZARDS.md +++ b/docs/KNOWN-HAZARDS.md @@ -1,76 +1,74 @@ # Known hazards This is the OMP adapter's conformance checklist. Each entry states a failure mode, the invariant that prevents it, the evidence boundary, and its source. An invariant is not covered merely because it is described here: the active requirement must point at production behavior and a focused test. -## 1. Delivery custody can be lost or mis-correlated +## 1. Delivery custody can be lost or assistant output can leak to a peer - **Failure:** Two SPT deliveries overlap, the active sender is overwritten, or - OMP rejects a submitted message after the listener has already accepted it. - The wrong peer can receive the next answer, or one accepted message can - disappear with no terminal outcome. -- **Invariant:** The extension serializes deliveries. From receipt until - completion it owns the sender, full event, submitted OMP turn, and terminal - outcome as one custody record. It advances only after sending either the - correlated answer or an explicit failure to that sender. Submission failure - is never treated as a successful empty answer. + ordinary assistant output is implicitly forwarded to a peer. +- **Invariant:** The extension serializes deliveries from receipt through their + OMP turn and releases each custody record on completion. It never converts + assistant output, turn failure, submission failure, or shutdown text into an + outbound peer message. Outbound messaging occurs only through explicit + `spt send` use or the `@<…@>` shortform. - **Mapping / notes:** The native OMP extension owns receipt, queueing, - `sendUserMessage`, turn completion, and reply. The ordinary OMP turn receives - a short message stub plus the full event context; peer content is never + `sendUserMessage`, and turn completion. The ordinary OMP turn receives a + short message stub plus the full event context; peer content is never submitted as a local slash command. - **cite:** ADR-0010 and the OMP extension lifecycle contract. ## 2. A dead listener leaves a healthy-looking but unreachable endpoint - **Failure:** The SPT listener exits while OMP remains open and bound. If the extension retries forever or merely logs the exit, the endpoint can stay advertised as online while no message can reach it. - **Invariant:** Unexpected listener exit triggers only a finite, deterministic restart schedule. A successful restart resumes delivery with custody intact. Exhausting the schedule performs SPT session-end and shuts down OMP loudly. There is no infinite restart loop and no healthy advertisement after the delivery path is gone. - **Mapping / notes:** Retry timers are extension-owned and cancellable during normal shutdown. The exhausted path uses the same serialized teardown seam as an explicit endpoint stop. - **cite:** ADR-0010; field issue HIGH-2/HIGH-4 successor invariant. ## 3. In-TUI session switching breaks endpoint identity - **Failure:** A bound endpoint switches, branches, creates, or resumes another OMP session from inside the TUI. The stable SPT endpoint id then points at a different transcript than queued messages, replies, history, digest, and durable mind state expect. - **Invariant:** One endpoint owns exactly one OMP session for its lifetime. The extension blocks every in-TUI action that would change that session. Selecting another session requires stopping the endpoint and relaunching it with the explicit native-resume role, producing a new deliberate bind. - **Mapping / notes:** This is stricter than merely rebinding on a session event. A rebind would preserve liveness while violating custody and history identity, so it is not an allowed recovery. - **cite:** ADR-0011. ## 4. The `omp` basename resolves to the wrong executable - **Failure:** Another program owns the bare `omp` token, or an adapter helper is advertised as a host binary. The broker can launch or bind the wrong process, while process listings conceal which application actually owns the endpoint. - **Invariant:** The manifest advertises only the genuine OMP host basename. The native launch shim resolves and validates Oh My Pi before launch, rejects collisions loudly, and then replaces itself or inherits the terminal unchanged so native OMP remains the PTY owner. - **Mapping / notes:** `omp-spt` is the adapter helper/release binary, never a hosted-harness match key. Fresh and resume launch use the same resolver. - **cite:** ADR-0009 and the retired bridge incident's executable-collision finding. diff --git a/docs/PARITY.md b/docs/PARITY.md index 7affc01..7b5be77 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1,145 +1,145 @@ # omp-spt capability parity record > This record compares `omp-spt` with its sister adapter, `claude-spt`. > Parity means the same user outcome, including agent-facing outcomes, through > the best OMP-native seam or a discoverable public `spt` command. It does not > require Claude command names, hooks, PTY translation, or source structure. > Worker endpoints remain intentionally excluded. ## Versioned comparison baseline -Current baseline: `omp-spt v0.3.4` → `SaberMage/claude-spt v0.21.0`. -`omp-spt` v0.2.1 was published at 2026-07-15 10:58:45 UTC. The first repository commit -after that release, `99287a24b8192dc280ad9baed84c2a15e6578d12`, landed at -2026-07-15 11:10:09 UTC and fixes the start of the work interval that produced v0.3.4. -`claude-spt` v0.21.0 was the latest published sister release at that instant, published from -`SaberMage/claude-spt` at 2026-07-15 08:48:31 UTC. The sister release lineage moved to -`BigscreenVR/claude-spt-bs` beginning with v0.22.0, after this parity baseline was fixed. - -This baseline identifies the exact sister behavior used to define v0.3.4 parity. It is not a +Current baseline: `omp-spt v0.3.5` → `BigscreenVR/claude-spt-bs v0.25.1`. +`omp-spt` v0.3.4 was published at 2026-07-16 10:48:22 UTC. The first repository commit +after that release, `413d6908d3a514edf93e0e53d4fb1f4b46ff7269`, landed at +2026-07-19 09:49:10 UTC and fixes the start of the work interval that produced v0.3.5. +`claude-spt-bs` v0.25.1 was the latest published sister release at that instant, published from +`BigscreenVR/claude-spt-bs` at 2026-07-19 04:55:53 UTC. + +This baseline identifies the exact sister behavior used to define v0.3.5 parity. It is not a claim that later `claude-spt` capabilities are covered. Any subsequent feature-parity work must first select and record the exact published `claude-spt` release being consulted. Every parity-informed `omp-spt` release must update this baseline to its own version; the release version-consistency gate rejects a stale `omp-spt` baseline. ## Proven shared endpoint surface The current adapters already share release install/update, broker-hosted fresh launch and attachment, native resume, ReadyAgent and LiveAgent roles, -post-spawn identity, inbound delivery and correlated replies, busy/idle +post-spawn identity, inbound delivery, explicit outbound messaging, busy/idle lifecycle, graceful shutdown, Psyche turns, commune/signoff storage, echo-commune, opaque history, digest extraction, and Windows x86-64 plus GNU Linux x86-64 release payloads. OMP keeps its stronger native invariants: immutable endpoint/session binding, one bounded custody queue, finite listener recovery, fail-closed exhaustion, -real OMP executable validation, and explicit failures rather than silent empty -outcomes. Capability parity does not weaken those guarantees. +real OMP executable validation, and strict separation between local assistant +output and explicit outbound peer messaging. Capability parity does not weaken +those guarantees. ## Agent-facing parity decisions Ordinary OMP sessions gain extension-native `/ready` and `/live` activation commands with argument completion or native selection dialogs. Explicit live auto-resume selects the most-recent compatible live identity; ordinary activation never guesses silently. The extension binds the current session and owns the authenticated listener, after which binding is immutable. Messages accepted during an active OMP turn enter at the first proven boundary before the next tool or model continuation. Custody and arrival order remain intact; a turn with no injectable boundary falls back to ordinary next-turn delivery rather than interruption. Cancelled, interrupted, and failed turns settle affected custody and restore receivability automatically. Only exhausted listener recovery closes the endpoint. Activated sessions receive a concise startup brief for identity, roster, messaging, continuity, lifecycle, subnet, version, and update operations. Targeted hints cover clear live-activation, identity, messaging, subnet, and checkpoint intent. Available compatible spt-core or adapter updates produce an actionable notice. Public OMP `omp-plugins` packaging supplies model-driven skills for commune, signoff, and durable-role inspection/editing. Commune checkpoint mode saves continuity, resets OMP context through native session APIs, and wakes the same endpoint without operator intervention. Setup is hybrid: an extension-native command owns deterministic probes and activation, while a packaged skill owns diagnosis, repair strategy, published installer use, subnet onboarding, and explicit operator handoffs for authentication or elevation. Assistant output may dispatch the explicit cross-harness peer-message shortform `@`. Ordinary `@` mentions have no side effect. Normal outbound messaging, roster, stop/shutdown, version, and subnet commands are taught through setup or startup context rather than duplicated as skills. OMP's native provider/profile routing satisfies alternate provider, account, and model routing parity; no `omp-spt:` analogue of Claude's CCS overlay is required. ## Release-target parity The supported target set is Windows x86-64 MSVC, GNU Linux x86-64, and a static Linux x86-64 musl helper compatibility tier for OMP-capable hosts where the GNU helper is incompatible. The musl tier requires full native endpoint acceptance on a representative target host; it does not imply generic Alpine support when OMP itself is unavailable. Neither sister adapter packages macOS or Arm64, so those remain explicit shared limitations rather than current parity gaps. The manifest version is canonical. Release gates reject disagreement in the helper crate, changelog heading, and versioned public documentation. Deterministic local documentation generation and drift checks gate changes. GitHub Pages publication runs the same gate before publishing. Each supported target retains a machine-readable release record naming the target, source commit and tag, artifact digests, acquisition result, and native -fresh/resume, message/reply, lifecycle, shutdown, and ReadyAgent/LiveAgent -acceptance outcomes. +fresh/resume, message/explicit-reply, lifecycle, shutdown, and +ReadyAgent/LiveAgent acceptance outcomes. ## Explicit non-goals - Worker endpoint advertisement or OMP-subagent endpoint topology. - macOS, Arm64, or 32-bit release payloads in this parity program. - A CCS adapter overlay where native OMP provider/profile routing supplies the same outcome. - Claude hooks, PTY translation, plugin reconciliation, or model-managed background polling. - Claude's retired `>>commune<<` output marker. - Public support promises for the helper binary's manifest-role subcommands. - Separate skills for send, roster, stop, version, or subnet. ## Delivery order Coverage lands in dependency order: 1. repair version and documentation truth, then prove packaged-skill discovery and injectable OMP event seams; 2. implement extension-owned ready/live activation, live auto-resume, safe-boundary delivery, and abnormal-turn receivability; 3. add startup briefs, targeted hints, update notices, packaged skills, checkpoint continuity, hybrid setup, and peer-message shortform; 4. add the musl target and durable machine-readable acceptance records for all supported targets. diff --git a/docs/SPT-CORE-FINDINGS.md b/docs/SPT-CORE-FINDINGS.md index fb00182..8011998 100644 --- a/docs/SPT-CORE-FINDINGS.md +++ b/docs/SPT-CORE-FINDINGS.md @@ -1620,80 +1620,81 @@ spt-hosted bringup on any fresh node silently hangs. **Per-spawn workaround (used for the E2E run):** attach fast after launch and accept the dialog — `(sleep 4; printf '\r'; sleep 4; printf '\x02d') | ssh -tt "spt rc "` (send Enter to select "1. Yes, I trust", then `ctrl-b d` to detach leaving CC bound). This does NOT persist reliably (a subsequent fresh endpoint in the same cwd re-hits the dialog), so it is a per-bringup manual step, not a one-time fix. **Asks (doyle / next planning pass):** a sanctioned **pre-trust seam** so spt-hosted bringup is truly headless on a virgin node — candidates: (a) adapter-docs: document that operators must trust-accept the cwd once (or ship a `settings.json` that pre-trusts), (b) a core/adapter diagnostic that detects the unbound-at-trust-dialog state and reports it LOUD instead of silent-hang (ties to F-027's fail-loud principle), (c) upstream: `--dangerously-skip-permissions` (or a new flag) covering the workspace-trust + settings.local consent for broker-PTY spawns. **Status:** FILED (doyle-accepted 2026-07-02) — next planning pass; partly CC-harness-side. ## F-027 — a profile whose launch binary is absent fails SILENT-UNBOUND, not loud at spawn/bind **Surfaced:** 2026-07-02 (perri, ENLYZEAM), during the E2E-0104 cross-node run. **doyle ruling: milestone item #12, dispatched to todlando — "missing profile binary must fail LOUD at spawn/bind, never silent-unbound." This field evidence is the seed for his red-first test.** **Repro (exact):** ENLYZEAM has no `ccs` binary on PATH (`Get-Command ccs` → not found; `ccs` is the model/billing multiplexer the shipped `claude-spt:ccs` profile launches). Running the shipped composite: ``` spt endpoint run --adapter claude-spt:ccs --id f015b-probe-enlz --create --start --subnet SPT_DEV → ENDPOINT_RUN:f015b-probe-enlz adapter=claude-spt:ccs session=… pid=… → ENDPOINT_RUN_STARTED:f015b-probe-enlz (attach with spt rc f015b-probe-enlz) ``` Both lines report success. But the `:ccs` overlay's spawn command (`ccs …`) fails because `ccs` isn't on PATH — and the endpoint never binds: `info.json.session_id` stays `""`, status stays `offline`, no CC/ccs process appears. **No error is surfaced anywhere** — not on the `endpoint run` output, not in `daemon status`, not in a log the operator would see. The endpoint sits permanently `offline`/`unbound` and the operator has no signal WHY (indistinguishable from F-026's trust-hang without process-table forensics). **Why it matters:** a mistyped profile, an uninstalled multiplexer, or a PATH gap all present identically as a silent dead endpoint. The spawn layer knows the exec failed (ENOENT / "not found") and should propagate it: `ENDPOINT_SPAWN_FAIL: profile=<:profile> — launch binary '' not found on PATH` (or equivalent), never a success line followed by silent unbound. Same success-without-effect genus as F015B/F-025, at the spawn seam. **Workaround (used):** created a node-local plain-`claude` composite via `spt adapter create-profile claude-spt probe --from ` (composite shape preserved, `ccs` dependency dropped) to run the F015B-class swap verify. **Status:** SEED FILED for todlando red-first (milestone #12, doyle-dispatched 2026-07-02). ## F-032 — Endpoint shutdown leaves the broker-hosted harness alive **Reported:** 2026-07-19 to hertz during omp-spt v0.3.4 native GNU acceptance. **Status:** confirmed spt-core defect; v0.3.4 release proceeded by explicit operator decision using `spt endpoint stop` for acceptance cleanup. On kitsubito with spt-core 0.33.0 and OMP 17.0.1, a disposable native omp-spt endpoint completed fresh launch, correlated message/reply, native resume, ReadyAgent, and LiveAgent checks. From a second terminal: ```sh spt endpoint shutdown omp-spt-accept-gnu-live ``` reported `Active -> Suspended`, but repeated endpoint listings retained the original address with `ready=false` and `alive=true`. The broker-hosted OMP process and its `spt api listen` child remained alive. Only `spt endpoint stop ` cleared the address and produced `alive=false` / `Offline`. Hertz grounded the ruling in spt-core's `CONTEXT.md`: `Suspended` is cold and session-closed, while the broker owns the hosted PTY/process. Core shutdown must synchronously reap that host after the boundary echo/cascade, clear address and liveness, and retain the `Suspended` lifecycle state. The adapter already handles native OMP `session_shutdown` by reaping its listener and calling `api session-end`; the published manifest contract exposes no core-to-harness shutdown callback, and none is required for this fix. The required core regression starts a real broker-hosted harness, shuts it down externally, and asserts host/listener exit, no replacement listener, no address, `alive=false`, `ready=false`, and an unreachable old host before the command succeeds. For v0.3.4, the operator explicitly accepted `spt endpoint stop` as the release-acceptance cleanup and directed stable promotion on 2026-07-19. The core defect remains open and must not be represented as fixed by the adapter release. + diff --git a/docs/adr/0010-native-delivery-self-heals-or-closes.md b/docs/adr/0010-native-delivery-self-heals-or-closes.md index 21a1dce..8c4a624 100644 --- a/docs/adr/0010-native-delivery-self-heals-or-closes.md +++ b/docs/adr/0010-native-delivery-self-heals-or-closes.md @@ -1,10 +1,10 @@ # Native delivery self-heals or closes the endpoint Status: accepted (2026-07-14) -The OMP SPT extension owns delivery custody from receipt through a correlated answer or explicit failure reply. If OMP rejects a submitted message, the extension reports the failure to that sender and advances the serialized queue; it never silently drops the message. If the SPT listener exits, the extension retries with bounded backoff. Exhausting that budget ends the SPT session and shuts down the hosted endpoint loudly, preventing a dead delivery path from remaining advertised as healthy. +The OMP SPT extension owns delivery custody from receipt through one serialized OMP turn, then releases it without creating an outbound message. Assistant output, submission errors, failed turns, and shutdown text remain local; a model or operator sends to a peer only through explicit `spt send` use or the `@<…@>` shortform. If the SPT listener exits, the extension retries with bounded backoff. Exhausting that budget ends the SPT session and shuts down the hosted endpoint loudly, preventing a dead delivery path from remaining advertised as healthy. Normal peer delivery uses the native OMP message-stub contract: the extension opens the turn with `` and supplies the complete SPT event envelope as context for that same turn. Peer text therefore remains opaque message content and cannot accidentally invoke an OMP slash command. diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs index a1d9ee7..4adfcf5 100644 --- a/tests/omp-extension.mjs +++ b/tests/omp-extension.mjs @@ -1,87 +1,86 @@ import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { createOmpSpt, decodeBody, drainEvents, - extractReply, parsePeerShortforms, runSpt, } from "../adapter/strings/omp-spt.mjs"; const flush = () => new Promise((resolve) => setImmediate(resolve)); function deferred() { let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, reject, resolve }; } let nextAssistantTimestamp = 1; function assistantMessage(content, options = {}) { return { role: "assistant", content, stopReason: options.stopReason ?? "stop", timestamp: options.timestamp ?? nextAssistantTimestamp++, ...options, }; } class FakeStream extends EventEmitter { setEncoding(encoding) { this.encoding = encoding; } end(input) { this.input = input; if (this.onEnd?.(input) === false) return; this.emit("finish"); } } class FakeChild extends EventEmitter { constructor(options = {}) { super(); this.stdin = new FakeStream(); this.stdout = new FakeStream(); this.stderr = new FakeStream(); this.exitCode = null; this.signalCode = null; this.kills = 0; this.killSignals = []; this.onKill = options.onKill; } close(code = 0, signal = null) { this.exitCode = code; this.signalCode = signal; this.emit("close", code, signal); } kill(signal = "SIGTERM") { this.kills += 1; this.killSignals.push(signal); const handled = this.onKill?.(signal, this); if (handled !== undefined) return handled; this.close(null, signal); return true; } } class FakeClock { constructor() { this.nextId = 1; this.timers = new Map(); } setTimeout(fn, delay) { const handle = { id: this.nextId++, unref() {} }; this.timers.set(handle, { fn, delay }); return handle; } clearTimeout(handle) { @@ -157,916 +156,779 @@ function createHarness(options = {}) { async input(title, placeholder) { inputs.push({ title, placeholder }); return options.onInput?.(title, placeholder) ?? options.inputResults?.shift(); }, }; const ctx = { ui, hasUI: options.hasUI ?? true, cwd: options.cwd ?? "C:\\work\\project", async compact(compactionOptions) { compactions.push(compactionOptions); return options.onCompact?.(compactionOptions); }, sessionManager: { getSessionId: () => options.sessionId ?? "session-1" }, shutdown() { shutdowns += 1; }, }; const pi = { zod: { z }, logger: { error(message, details) { errors.push({ message, details }); }, debug(message, details) { debug.push({ message, details }); }, }, on(name, handler) { const registered = handlers.get(name) ?? []; registered.push(handler); handlers.set(name, registered); }, registerCommand(name, command) { commands.set(name, command); }, registerTool(tool) { tools.set(tool.name, tool); }, sendMessage(message, delivery) { sentMessages.push({ message, delivery }); options.onSendMessage?.(message, delivery); }, sendUserMessage(content) { submitted.push(content); options.onSubmit?.(content); }, }; const runSptCommand = async (args, input, runOptions) => { const call = { args: [...args], input }; Object.defineProperty(call, "options", { value: runOptions }); calls.push(call); const overridden = await options.onRun?.(call); if (overridden !== undefined) return overridden; if (args[0] === "api" && args[3] === "bind") { return options.bindOutput ?? "BOUND endpoint token=token-123"; } return ""; }; const spawnProcess = (binary, args, spawnOptions) => { const child = new FakeChild(); child.binary = binary; child.args = [...args]; child.spawnOptions = spawnOptions; options.onSpawn?.(child); children.push(child); return child; }; const extension = createOmpSpt({ env: { SPT_ENDPOINT_ID: Object.hasOwn(options, "id") ? options.id : "omp-agent", OMP_SPT_SUBNET: options.subnet, OMP_SPT_SPT_BIN: "spt-test", }, checkUpdates: options.checkUpdates ?? false, fetchLatestAdapterVersion: options.fetchLatestAdapterVersion, platform: options.platform, acceptedBytesLimit: options.acceptedBytesLimit, acceptedQueueLimit: options.acceptedQueueLimit, restartDelaysMs: options.restartDelaysMs ?? [5, 10], - outcomeRetryDelaysMs: options.outcomeRetryDelaysMs ?? [], sessionEndRetryDelaysMs: options.sessionEndRetryDelaysMs ?? [], shutdownBudgetMs: options.shutdownBudgetMs, shutdownCommandTimeoutMs: options.shutdownCommandTimeoutMs, shortformCommandTimeoutMs: options.shortformCommandTimeoutMs, listenerStableMs: options.listenerStableMs ?? false, killForceMs: options.killForceMs ?? 4, killGraceMs: options.killGraceMs ?? 3, listenerBufferLimit: options.listenerBufferLimit, runSptCommand, spawnProcess, setTimeout: clock.setTimeout.bind(clock), clearTimeout: clock.clearTimeout.bind(clock), }); extension(pi); async function emit(name, event = {}) { let result; for (const handler of handlers.get(name) ?? []) { const returned = await handler({ type: name, ...event }, ctx); if (returned !== undefined) result = returned; } return result; } return { commands, confirmations, compactions, calls, children, clock, ctx, debug, emit, inputs, errors, handlers, notifications, selections, sentMessages, statuses, submitted, tools, get shutdowns() { return shutdowns; }, }; } function commandCalls(harness, command) { return harness.calls.filter((call) => call.args[0] === command); } function stateCalls(harness) { return harness.calls.filter((call) => call.args[0] === "api" && call.args[3] === "state"); } function assertNoAgentManagedPoll(harness) { assert.ok( !harness.calls.some((call) => call.args.includes("poll")) && !harness.children.some((child) => child.args.includes("poll")), "extension activation and delivery must never launch an agent-managed background poll", ); } -async function testParsingAndReplies() { +async function testParsing() { assert.equal(decodeBody('a<b>
"c"
legacy & &lt;'), 'a\n"c"\nlegacy & <'); const partialEnvelope = 'hello
wo'; const partial = drainEvents(`noise${partialEnvelope}`); assert.deepEqual(partial.events, []); assert.equal(partial.rest, partialEnvelope); const envelope = `${partial.rest}rld
`; const complete = drainEvents(`${envelope}skip`); assert.deepEqual(complete.events, [ { from: "doyle", body: "hello\nworld", envelope }, ]); assert.equal(complete.rest, ""); const literalEventBody = 'No inbound containing the message has surfaced.'; assert.deepEqual(drainEvents(literalEventBody).events, [ { from: "hertz", body: "No inbound containing the message has surfaced.", envelope: literalEventBody, }, ]); const truncatedA = 'truncatedvalid'; const nested = drainEvents(truncatedA); assert.deepEqual(nested.events, []); assert.equal(nested.rest, truncatedA); assert.match(nested.error.message, /nested EVENT/); assert.match( drainEvents('missing sender').error.message, /missing EVENT from/, ); assert.match( drainEvents('bad attrs').error.message, /malformed EVENT attributes/, ); assert.match( drainEvents('0123456789', { maxFrameChars: 32, }).error.message, /EVENT frame exceeded/, ); - assert.equal( - extractReply([ - assistantMessage([{ type: "text", text: "first" }]), - { role: "toolResult", content: [] }, - assistantMessage([ - { type: "text", text: "final " }, - { type: "text", text: "answer" }, - ]), - ]), - "final answer", - ); - assert.equal( - extractReply( - [ - assistantMessage("stale answer"), - { role: "user", content: '' }, - ], - '', - ), - "", - ); } // [unit->REQ-OMP-EXTENSION-CUSTODY] // [unit->REQ-OMP-SESSION-IMMUTABLE] // [unit->REQ-OMP-MESSAGE-CONTEXT] // [unit->REQ-OMP-NATIVE-TUI] async function testLifecycleCustodyAndContext() { const harness = createHarness({ subnet: "mesh-a" }); assert.deepEqual([...harness.handlers.keys()], [ "session_start", "session_before_switch", "session_before_branch", "context", "before_agent_start", "agent_start", "agent_end", "session_stop", "session_shutdown", ]); await harness.emit("session_start"); assert.deepEqual(harness.calls[0], { args: [ "api", "--adapter", "omp-spt", "bind", "omp-agent", "--set-session-id", "session-1", "--subnet", "mesh-a", ], input: undefined, }); assert.deepEqual(harness.calls[1].args, [ "api", "--adapter", "omp-spt", "state", "idle", "omp-agent", "--token", "token-123", ]); assert.equal(harness.children.length, 1); assert.equal(harness.children[0].binary, "spt-test"); assert.deepEqual(harness.children[0].args, [ "api", "--adapter", "omp-spt", "listen", "omp-agent", "--session-id", "session-1", "--subnet", "mesh-a", ]); for (const reason of ["new", "resume", "fork", "handoff"]) { assert.deepEqual(await harness.emit("session_before_switch", { reason }), { cancel: true }); assert.ok( harness.notifications.some(({ message }) => message.includes(`${reason} session switch`), ), ); } assert.deepEqual(await harness.emit("session_before_branch"), { cancel: true }); assert.ok( harness.notifications.some(({ message }) => message.includes("session branch")), ); const aliceEnvelope = 'hello<world
line
'; const bobEnvelope = 'second'; harness.children[0].stdout.emit("data", `${aliceEnvelope}${bobEnvelope}`); await flush(); assert.deepEqual(harness.submitted, ['']); assert.deepEqual( stateCalls(harness).map((call) => call.args[4]), ["idle", "busy"], ); const originalMessages = [{ role: "user", content: '' }]; const context = await harness.emit("context", { messages: originalMessages }); assert.equal(originalMessages[0].content, ''); assert.equal(context.messages[0].content, `\n\n${aliceEnvelope}`); const arrayContext = await harness.emit("context", { messages: [{ role: "user", content: [{ type: "text", text: '' }] }], }); assert.deepEqual(arrayContext.messages[0].content, [ { type: "text", text: '' }, { type: "text", text: `\n\n${aliceEnvelope}` }, ]); await harness.emit("agent_start"); assert.deepEqual( stateCalls(harness).map((call) => call.args[4]), ["idle", "busy"], "agent_start must not duplicate the already-honest busy transition", ); const aliceReply = assistantMessage([{ type: "text", text: "alice reply" }]); await harness.emit("agent_end", { messages: [ { role: "user", content: '' }, aliceReply, ], }); assert.deepEqual(harness.submitted, ['']); assert.deepEqual(harness.clock.delays(), [0]); await harness.clock.runNext(0); assert.deepEqual(harness.submitted, ['', '']); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ aliceReply, { role: "user", content: '' }, ], }); const outcomes = commandCalls(harness, "send"); - assert.equal(outcomes.length, 2); assert.deepEqual( - outcomes.map((call) => call.args), - [ - ["send", "alice", "--from", "omp-agent"], - ["send", "bob", "--from", "omp-agent"], - ], + outcomes, + [], + "ordinary assistant output must never be forwarded to a peer", ); - assert.equal(outcomes[0].input, "alice reply"); - assert.match(outcomes[1].input, /turn ended without an assistant response/); assert.deepEqual( stateCalls(harness).map((call) => call.args[4]), ["idle", "busy", "idle", "busy", "idle"], ); for (const call of [...stateCalls(harness), ...harness.calls.filter((candidate) => candidate.args[3] === "session-end")]) { assert.deepEqual(call.args.slice(-2), ["--token", "token-123"]); } await harness.emit("session_shutdown"); const ended = harness.calls.filter((call) => call.args[3] === "session-end"); assert.equal(ended.length, 1); assert.deepEqual(ended[0].args.slice(-2), ["--token", "token-123"]); assert.equal(harness.children[0].kills, 1); assert.deepEqual(harness.clock.delays(), []); } +// [unit->REQ-OMP-EXTENSION-CUSTODY] +// [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] +async function testLocalAssistantOutputDoesNotReplyToPeer() { + const harness = createHarness(); + await harness.emit("session_start"); + harness.children[0].stdout.emit( + "data", + 'peer reply', + ); + await flush(); + await harness.emit("context", { + messages: [{ role: "user", content: '' }], + }); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { role: "user", content: '' }, + { role: "user", content: "local user interjection" }, + assistantMessage("answer intended for the local user"), + ], + }); + + assert.deepEqual( + commandCalls(harness, "send"), + [], + "a local interjection must not be correlated back to the peer", + ); + assert.deepEqual(harness.clock.delays(), []); + assert.deepEqual( + harness.submitted, + [''], + "the received peer delivery completes without an implicit response", + ); + await harness.emit("session_shutdown"); +} + async function testDeferredBindLifecycleSerialization() { const busyBind = deferred(); const busyHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return busyBind.promise; }, }); const startingBusy = busyHarness.emit("session_start"); await flush(); const becomingBusy = busyHarness.emit("agent_start"); await flush(); assert.deepEqual(stateCalls(busyHarness), []); assert.equal(busyHarness.children.length, 0); busyBind.resolve("BOUND endpoint token=token-busy"); await Promise.all([startingBusy, becomingBusy]); assert.deepEqual( stateCalls(busyHarness).map((call) => call.args[4]), ["busy"], "agent_start before bind completion must suppress the stale idle publication", ); assert.equal(busyHarness.children.length, 1); await busyHarness.emit("session_shutdown"); assert.deepEqual(busyHarness.clock.delays(), []); const shutdownBind = deferred(); const shutdownHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return shutdownBind.promise; }, }); const startingShutdown = shutdownHarness.emit("session_start"); await flush(); const shuttingDown = shutdownHarness.emit("session_shutdown"); await flush(); assert.equal(shutdownHarness.children.length, 0); assert.equal( shutdownHarness.calls.filter((call) => call.args[3] === "session-end").length, 0, ); shutdownBind.resolve("BOUND endpoint token=token-shutdown"); await Promise.all([startingShutdown, shuttingDown]); assert.deepEqual(stateCalls(shutdownHarness), []); assert.equal(shutdownHarness.children.length, 0); assert.equal( shutdownHarness.calls.filter((call) => call.args[3] === "session-end").length, 1, ); assert.ok( !shutdownHarness.statuses.some(({ text }) => text === "spt:omp-agent"), "bind completion after shutdown must not restore live status", ); assert.deepEqual(shutdownHarness.clock.delays(), []); const hungBindHarness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "bind") return new Promise(() => {}); }, }); const hungStart = hungBindHarness.emit("session_start"); await flush(); const hungShutdown = hungBindHarness.emit("session_shutdown"); await flush(); assert.deepEqual(hungBindHarness.clock.delays().sort((a, b) => a - b), [300, 1_800]); await hungBindHarness.clock.runNext(300); await Promise.all([hungStart, hungShutdown]); assert.equal(hungBindHarness.children.length, 0); assert.equal( hungBindHarness.calls.filter((call) => call.args[3] === "session-end").length, 0, "session-end cannot run without a completed bind token", ); assert.deepEqual(hungBindHarness.clock.delays(), []); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] -async function testOutcomeSendRetriesAndExhaustion() { - let retryAttempts = 0; - const retryHarness = createHarness({ - outcomeRetryDelaysMs: [5, 10], - onRun(call) { - if (call.args[0] === "send" && call.args[1] === "retry") { - retryAttempts += 1; - if (retryAttempts < 3) throw new Error(`outcome failure ${retryAttempts}`); - } - }, - }); - await retryHarness.emit("session_start"); - retryHarness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - await retryHarness.emit("agent_start"); - const retryEnding = retryHarness.emit("agent_end", { - messages: [ - { role: "user", content: '' }, - assistantMessage("eventual outcome"), - ], - }); - await flush(); - assert.equal(commandCalls(retryHarness, "send").length, 1); - assert.deepEqual(retryHarness.clock.delays(), [5]); - await retryHarness.clock.runNext(5); - assert.equal(commandCalls(retryHarness, "send").length, 2); - assert.deepEqual(retryHarness.clock.delays(), [10]); - await retryHarness.clock.runNext(10); - await retryEnding; - assert.equal(commandCalls(retryHarness, "send").length, 3); - assert.equal(commandCalls(retryHarness, "send").at(-1).input, "eventual outcome"); - assert.equal(retryHarness.shutdowns, 0); - await retryHarness.emit("session_shutdown"); - assert.deepEqual(retryHarness.clock.delays(), []); - - const exhaustedHarness = createHarness({ - outcomeRetryDelaysMs: [7], - onRun(call) { - if (call.args[0] === "send") throw new Error("outcome channel unavailable"); - }, - }); - await exhaustedHarness.emit("session_start"); - const exhaustedListener = exhaustedHarness.children[0]; - exhaustedListener.stdout.emit( - "data", - 'work', - ); - await flush(); - await exhaustedHarness.emit("agent_start"); - const exhaustedEnding = exhaustedHarness.emit("agent_end", { - messages: [ - { role: "user", content: '' }, - assistantMessage("undeliverable outcome"), - ], - }); - await flush(); - assert.equal(commandCalls(exhaustedHarness, "send").length, 1); - assert.deepEqual(exhaustedHarness.clock.delays(), [7]); - await exhaustedHarness.clock.runNext(7); - await exhaustedEnding; - - assert.equal(commandCalls(exhaustedHarness, "send").length, 2); - assert.deepEqual( - stateCalls(exhaustedHarness).map((call) => call.args[4]), - ["idle", "busy"], - "exhausted custody must never be advertised idle", - ); - assert.equal(exhaustedHarness.shutdowns, 1); - assert.equal(exhaustedListener.kills, 1); - assert.equal( - exhaustedHarness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - ); - assert.ok( - exhaustedHarness.errors.some(({ message }) => - message.includes("could not send the outcome to exhausted"), - ), - ); - await exhaustedHarness.emit("session_shutdown"); - assert.deepEqual(exhaustedHarness.clock.delays(), []); -} // [unit->REQ-OMP-EXTENSION-CUSTODY] async function testSubmissionFailureAdvancesQueue() { const harness = createHarness({ onSubmit(content) { if (content === '') throw new Error("OMP prompt flow rejected input"); }, }); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", 'onetwo', ); await flush(); - const firstOutcome = commandCalls(harness, "send"); - assert.equal(firstOutcome.length, 1); - assert.deepEqual(firstOutcome[0].args, ["send", "broken", "--from", "omp-agent"]); - assert.match(firstOutcome[0].input, /could not submit your message to OMP/); + assert.deepEqual( + commandCalls(harness, "send"), + [], + "a rejected local submission must not message the peer implicitly", + ); assert.deepEqual(harness.clock.delays(), [0]); await harness.clock.runNext(0); assert.deepEqual(harness.submitted, ['', '']); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: '' }, assistantMessage("next reply"), ], }); - const outcomes = commandCalls(harness, "send"); - assert.equal(outcomes.length, 2); assert.deepEqual( - outcomes.map((call) => call.args[1]), - ["broken", "next"], + commandCalls(harness, "send"), + [], + "assistant output for the next delivery must remain local", ); - assert.equal(outcomes[1].input, "next reply"); await harness.emit("session_shutdown"); assert.deepEqual(harness.clock.delays(), []); } async function testFailedIdleRecoveryFailsClosed() { let idleCalls = 0; const harness = createHarness({ onSubmit() { throw new Error("OMP prompt flow rejected input"); }, onRun(call) { if (call.args[0] === "api" && call.args[3] === "state" && call.args[4] === "idle") { idleCalls += 1; if (idleCalls === 2) throw new Error("state channel unavailable"); } }, }); await harness.emit("session_start"); harness.children[0].stdout.emit("data", 'one'); await flush(); - assert.equal(commandCalls(harness, "send").length, 1); - assert.match(commandCalls(harness, "send")[0].input, /could not submit your message to OMP/); + assert.deepEqual(commandCalls(harness, "send"), []); assert.equal(harness.shutdowns, 1); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.ok( harness.errors.some(({ message }) => message.includes("could not restore idle state after a failed submission"), ), ); assert.deepEqual(harness.clock.delays(), []); } // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] async function testListenerRestartExhaustion() { const harness = createHarness({ restartDelaysMs: [5, 10] }); await harness.emit("session_start"); const first = harness.children[0]; first.emit("close", 7); assert.deepEqual(harness.clock.delays(), [5]); await harness.clock.runNext(5); const second = harness.children[1]; second.stdout.emit("data", 'half'); second.emit("error", new Error("listener crashed")); second.emit("close", 8); await flush(); assert.deepEqual(harness.clock.delays(), [10], "error plus close schedules one restart"); await harness.clock.runNext(10); const third = harness.children[2]; third.emit("close", 9); await flush(); assert.equal(harness.children.length, 3); assert.equal(harness.shutdowns, 1); assert.ok( harness.errors.some(({ message }) => message.includes("listener restart budget exhausted")), ); assert.ok( harness.notifications.some(({ message, type }) => type === "error" && message.includes("listener restart budget exhausted"), ), ); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); await harness.emit("session_shutdown"); assert.equal( harness.calls.filter((call) => call.args[3] === "session-end").length, 1, "fatal shutdown and lifecycle shutdown share one teardown", ); } async function testListenerStableIntervalResetsRetries() { const harness = createHarness({ listenerStableMs: 20, restartDelaysMs: [5, 10], }); await harness.emit("session_start"); await harness.emit("agent_start"); assert.deepEqual(harness.clock.delays(), [20]); harness.children[0].emit("close", 1); assert.deepEqual(harness.clock.delays(), [5]); await harness.clock.runNext(5); const shortLived = harness.children[1]; shortLived.stdout.emit( "data", 'a parsed event is not stability', ); shortLived.emit("close", 2); assert.deepEqual( harness.clock.delays(), [10], "a parsed event followed by an immediate crash remains in the consecutive crash loop", ); await harness.clock.runNext(10); const stable = harness.children[2]; assert.deepEqual(harness.clock.delays(), [20]); await harness.clock.runNext(20); stable.emit("close", 3); assert.deepEqual( harness.clock.delays(), [5], "a listener surviving the stable interval resets the next retry to attempt one", ); assert.match( harness.notifications.filter(({ type }) => type === "warning").at(-1).message, /restarting 1\/2 in 5ms/, ); await harness.clock.runNext(5); assert.equal(harness.children.length, 4); await harness.emit("session_shutdown"); assert.deepEqual(harness.clock.delays(), []); } -// [unit->REQ-OMP-EXTENSION-CUSTODY] -// [unit->REQ-OMP-LISTENER-FAIL-CLOSED] -async function testFatalTeardownAwaitsInFlightOutcome() { - let releaseOutcome; - const outcomeGate = new Promise((resolve) => { - releaseOutcome = resolve; - }); - const harness = createHarness({ - restartDelaysMs: [], - onRun(call) { - if (call.args[0] === "send" && call.args[1] === "slow") return outcomeGate; - }, - }); - await harness.emit("session_start"); - harness.children[0].stdout.emit("data", 'work'); - await flush(); - await harness.emit("agent_start"); - const ending = harness.emit("agent_end", { - messages: [ - { role: "user", content: '' }, - assistantMessage("finished"), - ], - }); - await flush(); - assert.equal(commandCalls(harness, "send").length, 1); - - harness.children[0].emit("close", 11); - await flush(); - assert.equal(harness.shutdowns, 0, "fatal teardown must join the sender outcome"); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 0); - - const shutdown = harness.emit("session_shutdown"); - await flush(); - assert.equal( - harness.calls.filter((call) => call.args[3] === "session-end").length, - 0, - "concurrent lifecycle shutdown must join fatal custody teardown", - ); - for (const reason of ["new", "resume", "fork", "handoff"]) { - assert.deepEqual( - await harness.emit("session_before_switch", { reason }), - { cancel: true }, - `teardown must keep blocking the ${reason} switch while custody is pending`, - ); - } - assert.deepEqual( - await harness.emit("session_before_branch"), - { cancel: true }, - "teardown must keep blocking branches while custody is pending", - ); - - releaseOutcome(); - await Promise.all([ending, shutdown]); - await flush(); - assert.equal(harness.shutdowns, 1); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.deepEqual(harness.clock.delays(), []); -} async function testSessionEndRetriesAfterTransientFailure() { const firstEnd = deferred(); let endAttempts = 0; const harness = createHarness({ restartDelaysMs: [], sessionEndRetryDelaysMs: [5], onRun(call) { if (call.args[0] === "api" && call.args[3] === "session-end") { endAttempts += 1; if (endAttempts === 1) return firstEnd.promise; } }, }); await harness.emit("session_start"); harness.children[0].emit("close", 12); await flush(); assert.equal( harness.calls.filter((call) => call.args[3] === "session-end").length, 1, ); firstEnd.reject(new Error("transient teardown failure")); await flush(); assert.equal(harness.shutdowns, 0, "fatal close must wait for the bounded teardown retry"); assert.deepEqual(harness.clock.delays(), [5]); assert.ok( harness.errors.some( ({ message, details }) => message.includes("session teardown failed; retrying") && details.error.includes("transient teardown failure"), ), ); await harness.clock.runNext(5); await flush(); const shutdown = harness.emit("session_shutdown"); await shutdown; await flush(); assert.equal( harness.calls.filter((call) => call.args[3] === "session-end").length, 2, "normal fatal teardown may retry before bounded lifecycle shutdown begins", ); assert.equal(harness.shutdowns, 1); assert.deepEqual(harness.clock.delays(), []); } async function testHumanBusyFailureFailsClosed() { const harness = createHarness({ onRun(call) { if (call.args[0] === "api" && call.args[3] === "state" && call.args[4] === "busy") { throw new Error("state channel unavailable"); } }, }); await harness.emit("session_start"); await harness.emit("agent_start"); assert.equal(harness.shutdowns, 1); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.ok( harness.errors.some(({ message }) => message.includes("could not mark the endpoint busy")), ); assert.equal(harness.children[0].kills, 1); assert.deepEqual(harness.clock.delays(), []); } // [unit->REQ-OMP-EXTENSION-CUSTODY] // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] -async function testShutdownReapsAndFailsQueuedCustody() { +async function testShutdownReapsAndReleasesQueuedCustody() { const harness = createHarness(); await harness.emit("session_start"); const listener = harness.children[0]; listener.stdout.emit( "data", 'onetwo', ); await flush(); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: '' }, assistantMessage("done"), ], }); assert.deepEqual(harness.clock.delays(), [0]); await harness.emit("session_shutdown"); assert.equal(listener.kills, 1); assert.deepEqual(harness.clock.delays(), []); assert.deepEqual( - commandCalls(harness, "send").map((call) => call.args[1]), - ["first", "queued"], + commandCalls(harness, "send"), + [], + "shutdown must not synthesize outbound peer messages", ); - assert.match(commandCalls(harness, "send")[1].input, /OMP session shut down/); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.statuses.at(-1), { key: "omp-spt", text: undefined }); assert.equal(harness.shutdowns, 0, "normal lifecycle shutdown must not recursively shut down OMP"); } async function testRunSptRejectsStdinErrorsAndHungCommands() { const epipeClock = new FakeClock(); const epipeChild = new FakeChild(); const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); epipeChild.stdin.onEnd = () => { epipeChild.stdin.emit("error", epipe); return false; }; await assert.rejects( runSpt(["send", "peer", "--from", "omp-agent"], "reply", { clearTimeout: epipeClock.clearTimeout.bind(epipeClock), commandTimeoutMs: 20, killForceMs: 4, killGraceMs: 3, setTimeout: epipeClock.setTimeout.bind(epipeClock), spawnProcess: () => epipeChild, }), (error) => error === epipe && error.code === "EPIPE", ); assert.deepEqual(epipeChild.killSignals, ["SIGTERM"]); assert.deepEqual(epipeClock.delays(), []); const fastExitClock = new FakeClock(); const fastExitChild = new FakeChild(); fastExitChild.stdin.onEnd = () => { fastExitChild.close(0); return false; }; await assert.rejects( runSpt(["send", "peer", "--from", "omp-agent"], "reply", { clearTimeout: fastExitClock.clearTimeout.bind(fastExitClock), commandTimeoutMs: 20, killForceMs: 4, killGraceMs: 3, setTimeout: fastExitClock.setTimeout.bind(fastExitClock), spawnProcess: () => fastExitChild, }), /exited before stdin completed/, ); assert.deepEqual(fastExitChild.killSignals, []); assert.deepEqual(fastExitClock.delays(), []); const commandCases = [ ["api", "--adapter", "omp-spt", "bind", "omp-agent"], ["send", "peer", "--from", "omp-agent"], ["api", "--adapter", "omp-spt", "state", "idle", "omp-agent"], ["api", "--adapter", "omp-spt", "session-end", "omp-agent"], ]; for (const args of commandCases) { const clock = new FakeClock(); const child = new FakeChild(); const pending = runSpt(args, args[0] === "send" ? "outcome" : undefined, { clearTimeout: clock.clearTimeout.bind(clock), commandTimeoutMs: 7, killForceMs: 4, killGraceMs: 3, setTimeout: clock.setTimeout.bind(clock), spawnProcess: () => child, }); const rejected = assert.rejects(pending, /timed out after 7ms/); assert.deepEqual(clock.delays(), [7]); await clock.runNext(7); await rejected; assert.deepEqual(child.killSignals, ["SIGTERM"]); assert.deepEqual(clock.delays(), []); } } // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] async function testListenerTerminationEscalatesAndReaps() { const harness = createHarness({ killForceMs: 4, killGraceMs: 3, onSpawn(child) { child.onKill = (signal) => { if (signal === "SIGKILL") child.close(null, signal); @@ -1078,452 +940,233 @@ async function testListenerTerminationEscalatesAndReaps() { const listener = harness.children[0]; const shutdown = harness.emit("session_shutdown"); await flush(); assert.deepEqual(listener.killSignals, ["SIGTERM"]); assert.deepEqual(harness.clock.delays().sort((a, b) => a - b), [3, 1_800]); await harness.clock.runNext(3); await shutdown; assert.deepEqual(listener.killSignals, ["SIGTERM", "SIGKILL"]); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); const errorHarness = createHarness({ killForceMs: 4, killGraceMs: 3, onSpawn(child) { child.onKill = (signal) => { if (signal === "SIGKILL") child.close(null, signal); return true; }; }, }); await errorHarness.emit("session_start"); const erroredListener = errorHarness.children[0]; erroredListener.emit("error", new Error("listener pipe failed")); const concurrentShutdown = errorHarness.emit("session_shutdown"); await flush(); assert.deepEqual(erroredListener.killSignals, ["SIGTERM"]); assert.deepEqual(errorHarness.clock.delays().sort((a, b) => a - b), [3, 1_800]); await errorHarness.clock.runNext(3); await concurrentShutdown; assert.deepEqual(erroredListener.killSignals, ["SIGTERM", "SIGKILL"]); assert.equal( errorHarness.calls.filter((call) => call.args[3] === "session-end").length, 1, ); assert.deepEqual(errorHarness.clock.delays(), []); } // [unit->REQ-OMP-EXTENSION-CUSTODY] // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] async function testProtocolCorruptionFailsClosed() { async function failProtocol(payload, expected, options = {}) { const harness = createHarness({ restartDelaysMs: [], ...options }); await harness.emit("session_start"); harness.children[0].stdout.emit("data", payload); await flush(); assert.equal(harness.shutdowns, 1); assert.deepEqual(harness.submitted, []); assert.deepEqual(commandCalls(harness, "send"), []); assert.ok( harness.errors.some( ({ message, details }) => message.includes("listener protocol corruption") && expected.test(details.error), ), ); assert.equal(harness.children[0].kills, 1); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); await harness.emit("session_shutdown"); return harness; } const truncatedA = 'truncatedvalid'; const nested = await failProtocol(truncatedA, /nested EVENT/); assert.deepEqual( commandCalls(nested, "send").map((call) => call.args[1]), [], "the later valid b frame must not be merged into or consumed as a", ); await failProtocol('missing sender', /missing EVENT from/); await failProtocol('bad attrs', /malformed EVENT attributes/); await failProtocol('never closes'.padEnd(80, "x"), /buffer exceeded/, { listenerBufferLimit: 64, }); } // [unit->REQ-OMP-EXTENSION-CUSTODY] // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] -async function testInboundQueueOverflowReturnsAcceptedCustody() { +async function testInboundQueueOverflowReleasesAcceptedCustody() { const frames = ["a", "b", "overflow"].map( (from) => `work`, ); const harness = createHarness({ acceptedQueueLimit: 2, restartDelaysMs: [], }); await harness.emit("session_start"); harness.children[0].stdout.emit("data", frames.join("")); await flush(); assert.equal(harness.shutdowns, 1); assert.deepEqual(harness.submitted, []); assert.deepEqual( - commandCalls(harness, "send").map((call) => call.args[1]), - ["a", "b", "overflow"], - "every accepted item and the capacity-refused item receive an explicit terminal failure", + commandCalls(harness, "send"), + [], + "capacity failure must not synthesize outbound peer messages", ); - for (const call of commandCalls(harness, "send")) { - assert.match(call.input, /endpoint stopped before your message could complete/); - } assert.ok( harness.errors.some(({ message }) => message.includes("inbound custody capacity exceeded"), ), ); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); await harness.emit("session_shutdown"); const byteFirst = 'x'; const byteOverflow = '😀'; const byteHarness = createHarness({ acceptedBytesLimit: Buffer.byteLength(byteFirst, "utf8") + byteOverflow.length, acceptedQueueLimit: 10, restartDelaysMs: [], }); await byteHarness.emit("session_start"); byteHarness.children[0].stdout.emit("data", `${byteFirst}${byteOverflow}`); await flush(); - assert.deepEqual( - commandCalls(byteHarness, "send").map((call) => call.args[1]), - ["a", "b"], - ); + assert.deepEqual(commandCalls(byteHarness, "send"), []); assert.equal(byteHarness.shutdowns, 1); assert.deepEqual(byteHarness.clock.delays(), []); await byteHarness.emit("session_shutdown"); } -async function testSessionStopAwaitsOutcomeWithoutEndingEndpoint() { - const firstOutcome = deferred(); - let attempts = 0; - const harness = createHarness({ - outcomeRetryDelaysMs: [5], - onRun(call) { - if (call.args[0] === "send" && call.args[1] === "awaited") { - attempts += 1; - if (attempts === 1) return firstOutcome.promise; - } - }, - }); - await harness.emit("session_start"); - harness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - await harness.emit("agent_start"); - const messages = [ - { role: "user", content: '' }, - assistantMessage("completed outcome"), - ]; - const agentEnd = harness.emit("agent_end", { messages }); - await flush(); - const sessionStop = harness.emit("session_stop", { messages }); - await flush(); - assert.equal(commandCalls(harness, "send").length, 1); - assert.equal(harness.children[0].kills, 0); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 0); - - firstOutcome.reject(new Error("transient delayed outcome failure")); - await flush(); - assert.deepEqual(harness.clock.delays(), [5]); - await harness.clock.runNext(5); - await Promise.all([agentEnd, sessionStop]); - assert.equal(commandCalls(harness, "send").length, 2); - assert.equal(commandCalls(harness, "send").at(-1).input, "completed outcome"); - assert.equal(harness.children[0].kills, 0, "ordinary session_stop must leave the endpoint live"); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 0); - - await harness.emit("session_shutdown"); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.equal(harness.children[0].kills, 1); - assert.deepEqual(harness.clock.delays(), []); -} // [unit->REQ-OMP-EXTENSION-CUSTODY] // [unit->REQ-OMP-LISTENER-FAIL-CLOSED] async function testShutdownFallbackStaysBelowHostCap() { const never = new Promise(() => {}); const harness = createHarness({ shutdownBudgetMs: 1_800, shutdownCommandTimeoutMs: 300, onRun(call) { - if (call.args[0] === "send" || call.args[3] === "session-end") return never; + if (call.args[3] === "session-end") return never; }, }); await harness.emit("session_start"); - await harness.emit("agent_start"); - harness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - const shutdown = harness.emit("session_shutdown"); await flush(); + assert.deepEqual( harness.clock.delays().sort((a, b) => a - b), [300, 1_800], - "queued custody has a short command timeout inside the 2s host cap", + "session-end has a short command timeout inside the 2s host cap", ); await harness.clock.runNext(300); - assert.equal(commandCalls(harness, "send").length, 1); - assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); - assert.deepEqual(harness.clock.delays().sort((a, b) => a - b), [300, 1_800]); - await harness.clock.runNext(300); await shutdown; assert.equal(harness.children[0].kills, 1); - assert.ok( - harness.errors.some(({ message }) => - message.includes("could not return custody to queued"), - ), - ); + assert.deepEqual(commandCalls(harness, "send"), []); assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); assert.deepEqual(harness.clock.delays(), []); assert.ok(1_800 < 2_000); - - const queuedGates = [deferred(), deferred()]; - let queuedSendIndex = 0; - const concurrentHarness = createHarness({ - onRun(call) { - if (call.args[0] === "send") { - const gate = queuedGates[queuedSendIndex]; - queuedSendIndex += 1; - return gate.promise; - } - }, - }); - await concurrentHarness.emit("session_start"); - await concurrentHarness.emit("agent_start"); - concurrentHarness.children[0].stdout.emit( - "data", - 'onetwo', - ); - await flush(); - const concurrentShutdown = concurrentHarness.emit("session_shutdown"); - await flush(); - assert.deepEqual( - commandCalls(concurrentHarness, "send").map((call) => call.args[1]), - ["queued-a", "queued-b"], - "all pending custody failures must start concurrently", - ); - assert.deepEqual( - concurrentHarness.clock.delays().sort((a, b) => a - b), - [300, 300, 1_800], - ); - for (const gate of queuedGates) gate.resolve(); - await concurrentShutdown; - assert.equal( - concurrentHarness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - ); - assert.deepEqual(concurrentHarness.clock.delays(), []); - - const busyGate = deferred(); - const dispatchHarness = createHarness({ - onRun(call) { - if (call.args[0] === "api" && call.args[3] === "state" && call.args[4] === "busy") { - return busyGate.promise; - } - }, - }); - await dispatchHarness.emit("session_start"); - dispatchHarness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - assert.deepEqual(dispatchHarness.submitted, []); - assert.equal( - stateCalls(dispatchHarness).filter((call) => call.args[4] === "busy").length, - 1, - ); - const dispatchShutdown = dispatchHarness.emit("session_shutdown"); - await dispatchShutdown; - assert.deepEqual(dispatchHarness.submitted, []); - assert.deepEqual( - commandCalls(dispatchHarness, "send").map((call) => call.args[1]), - ["dispatching"], - ); - assert.match(commandCalls(dispatchHarness, "send")[0].input, /OMP session shut down/); - assert.equal( - dispatchHarness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - ); - assert.equal(dispatchHarness.shutdowns, 0); - assert.deepEqual(dispatchHarness.clock.delays(), []); - busyGate.resolve(); - await flush(); - assert.deepEqual(dispatchHarness.submitted, []); - - const inFlightOutcome = deferred(); - let inFlightAttempts = 0; - const inFlightHarness = createHarness({ - onRun(call) { - if (call.args[0] === "send" && call.args[1] === "in-flight") { - inFlightAttempts += 1; - if (inFlightAttempts === 1) return inFlightOutcome.promise; - } - }, - }); - await inFlightHarness.emit("session_start"); - inFlightHarness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - await inFlightHarness.emit("agent_start"); - const ending = inFlightHarness.emit("agent_end", { - messages: [ - { role: "user", content: '' }, - assistantMessage("answer racing shutdown"), - ], - }); - await flush(); - assert.equal(commandCalls(inFlightHarness, "send").length, 1); - const inFlightShutdown = inFlightHarness.emit("session_shutdown"); - await Promise.all([ending, inFlightShutdown]); - assert.deepEqual( - commandCalls(inFlightHarness, "send").map((call) => call.args[1]), - ["in-flight", "in-flight"], - ); - assert.match(commandCalls(inFlightHarness, "send")[1].input, /OMP session shut down/); - assert.equal( - inFlightHarness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - ); - assert.equal(inFlightHarness.shutdowns, 0); - assert.deepEqual(inFlightHarness.clock.delays(), []); - inFlightOutcome.resolve(); - await flush(); - assert.equal(commandCalls(inFlightHarness, "send").length, 2); - - const hardCapHarness = createHarness({ - shutdownBudgetMs: 1_800, - shutdownCommandTimeoutMs: 5_000, - killForceMs: 100, - killGraceMs: 100, - onRun(call) { - if (call.args[0] === "send") return never; - }, - }); - await hardCapHarness.emit("session_start"); - await hardCapHarness.emit("agent_start"); - hardCapHarness.children[0].stdout.emit( - "data", - 'work', - ); - await flush(); - const hardCappedShutdown = hardCapHarness.emit("session_shutdown"); - await flush(); - assert.deepEqual( - hardCapHarness.clock.delays().sort((a, b) => a - b), - [600, 1_800], - ); - await hardCapHarness.clock.runNext(600); - await hardCappedShutdown; - assert.equal( - hardCapHarness.calls.filter((call) => call.args[3] === "session-end").length, - 1, - "the phase clamp must reserve time for one session-end attempt", - ); - assert.ok( - !hardCapHarness.errors.some(({ message }) => - message.includes("bounded shutdown expired"), - ), - ); - assert.equal(hardCapHarness.children[0].kills, 1); - assert.deepEqual(hardCapHarness.clock.delays(), []); } // [unit->REQ-PARITY-READY-ACTIVATION] // [unit->REQ-PARITY-LIVE-ACTIVATION] async function testNativeActivationCommandsAndErrors() { const inert = createHarness({ id: null }); assert.deepEqual([...inert.commands.keys()], ["ready", "live"]); assert.ok(inert.tools.has("spt_checkpoint")); await inert.emit("session_start"); assert.deepEqual(inert.calls, [], "an ordinary OMP session must remain lifecycle-inert"); assert.equal( await inert.emit("session_before_switch", { reason: "new" }), undefined, "an unbound extension must not block native session changes", ); await inert.commands.get("ready").handler("--auto", inert.ctx); await inert.commands.get("live").handler("two identities", inert.ctx); assert.deepEqual(inert.calls, [], "invalid activation syntax must not bind or guess"); assert.ok( inert.notifications.some(({ message }) => message.includes("supported only by `/live`")), ); assert.ok(inert.notifications.some(({ message }) => message.includes("Usage: /live"))); await inert.commands.get("ready").handler("ready-one", inert.ctx); const readyBind = inert.calls.find((call) => call.args[3] === "bind"); assert.deepEqual(readyBind.args, [ "api", "--adapter", "omp-spt", "bind", "ready-one", "--set-session-id", "session-1", "--type", "ready_agent", ]); assert.deepEqual(inert.children[0].args, [ "api", "--adapter", "omp-spt", "listen", "ready-one", "--session-id", "session-1", ]); assert.ok( inert.notifications.some(({ message, type }) => type === "info" && message.includes("ready endpoint ready-one"), ), ); await inert.commands.get("live").handler("other-id", inert.ctx); assert.equal(inert.calls.filter((call) => call.args[3] === "bind").length, 1); assert.ok( inert.notifications.some(({ message }) => message.includes("immutably bound to ready-one")), ); assertNoAgentManagedPoll(inert); await inert.emit("session_shutdown"); const activationGate = deferred(); const activating = createHarness({ id: null, onRun(call) { if (call.args[3] === "bind") return activationGate.promise; }, }); await activating.emit("session_start"); const activation = activating.commands.get("ready").handler("activation-race", activating.ctx); await flush(); assert.deepEqual( await activating.emit("session_before_switch", { reason: "new" }), { cancel: true }, "session switching must be blocked from activation start, before bind returns a token", ); assert.deepEqual( await activating.emit("session_before_branch"), { cancel: true }, "session branching must be blocked throughout activation convergence", ); activationGate.resolve("BOUND endpoint token=token-race"); @@ -1839,289 +1482,283 @@ async function testStartupBriefHintsAndUpdateNotices() { } else if (call.args[0] === "adapter" && call.args[1] === "version") { value = "0.2.0"; } else { return; } return new Promise((resolve) => { delayedClock.setTimeout(() => resolve(value), 5_000); }); }, }); delayedClock = delayed.clock; await delayed.emit("session_start"); let firstContextSettled = false; const delayedFirstContext = delayed .emit("before_agent_start", { prompt: "Continue.", systemPrompt: [] }) .then((result) => { firstContextSettled = true; return result; }); await flush(); assert.equal( firstContextSettled, true, "the first-turn context must not await slow local update commands", ); const firstContext = await delayedFirstContext; assert.ok(firstContext.systemPrompt.at(-1).includes("OMP SPT endpoint")); assert.ok(!firstContext.systemPrompt.at(-1).includes("OMP SPT updates:")); assert.deepEqual(delayed.clock.delays(), [5_000, 5_000, 5_000, 5_000]); for (let index = 0; index < 4; index += 1) await delayed.clock.runNext(5_000); assert.equal(delayed.sentMessages.length, 1); assert.deepEqual(delayed.sentMessages[0].delivery, { deliverAs: "nextTurn", triggerTurn: false, }); assert.match(delayed.sentMessages[0].message.content, /spt-core v1\.2\.0 is available/); assert.match(delayed.sentMessages[0].message.content, /omp-spt v0\.3\.0 is available/); await delayed.emit("session_shutdown"); } // [unit->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] // [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function testActiveTurnBoundaryDeliveryAndFallback() { const harness = createHarness(); await harness.emit("session_start"); await harness.emit("agent_start"); const firstEnvelope = 'one'; const secondEnvelope = 'two'; harness.children[0].stdout.emit("data", `${firstEnvelope}${secondEnvelope}`); await flush(); assert.deepEqual(harness.submitted, []); const boundary = await harness.emit("context", { messages: [{ role: "user", content: "operator prompt" }], }); assert.equal( boundary.messages.at(-1).content, `\n\n${firstEnvelope}`, "the first accepted message must enter at the next model boundary", ); const afterToolBoundary = await harness.emit("context", { messages: [ { role: "user", content: "operator prompt" }, assistantMessage("", { stopReason: "toolUse", toolCalls: [{ name: "read" }] }), { role: "toolResult", content: "tool output" }, ], }); assert.equal( afterToolBoundary.messages.at(-1).content, `\n\n${firstEnvelope}`, "ephemeral context must be re-injected before every later model continuation", ); await harness.emit("agent_end", { messages: [ { role: "user", content: "operator prompt" }, assistantMessage("", { stopReason: "toolUse", toolCalls: [{ name: "read" }] }), { role: "toolResult", content: "tool output" }, assistantMessage("first outcome"), ], }); - assert.equal(commandCalls(harness, "send")[0].args[1], "first"); - assert.equal(commandCalls(harness, "send")[0].input, "first outcome"); + assert.deepEqual( + commandCalls(harness, "send"), + [], + "assistant output at an active boundary must remain local", + ); assert.deepEqual(harness.clock.delays(), [0]); await harness.clock.runNext(0); assert.deepEqual( harness.submitted, [''], "a later accepted message must preserve order and fall back to an ordinary next turn", ); assertNoAgentManagedPoll(harness); await harness.emit("session_shutdown"); } // [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function testAbnormalTurnsRestoreReceivability() { for (const [label, completionEvent] of [ ["cancelled", "session_stop"], ["interrupted", "session_stop"], ["failed", "agent_end"], ]) { const harness = createHarness(); await harness.emit("session_start"); await harness.emit("agent_start"); harness.children[0].stdout.emit( "data", `work`, ); await flush(); const boundary = await harness.emit("context", { messages: [{ role: "user", content: "active work" }], }); await harness.emit(completionEvent, { messages: boundary.messages }); - const outcome = commandCalls(harness, "send")[0]; - assert.equal(outcome.args[1], label); - assert.match(outcome.input, /turn ended without an assistant response/); + assert.deepEqual(commandCalls(harness, "send"), []); assert.equal(stateCalls(harness).at(-1).args[4], "idle"); harness.children[0].stdout.emit( "data", `next`, ); await flush(); assert.deepEqual(harness.submitted, [``]); await harness.emit("session_shutdown"); } } async function testCompletionStopReasonGatesSideEffects() { - for (const [stopReason, expected] of [ - ["aborted", /aborted before completion/], - ["error", /failed before completion: provider unavailable/], - ]) { + for (const stopReason of ["aborted", "error"]) { const harness = createHarness(); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", `work`, ); await flush(); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ { role: "user", content: `` }, assistantMessage("@partial output", { stopReason, errorMessage: stopReason === "error" ? "provider unavailable" : undefined, }), ], }); const sends = commandCalls(harness, "send"); assert.deepEqual( - sends.map((call) => call.args[1]), - [`${stopReason}-sender`], - `${stopReason} partial output must settle custody without peer-message side effects`, + sends, + [], + `${stopReason} partial output must not produce peer-message side effects`, ); - assert.match(sends[0].input, expected); - assert.ok(!sends[0].input.includes("partial output")); assert.deepEqual(harness.sentMessages, []); assert.equal(stateCalls(harness).at(-1).args[4], "idle"); await harness.emit("session_shutdown"); } } -async function testCompactionSafeReplyCorrelation() { +async function testCompactionPreservesLocalAssistantOutput() { const highHistory = Array.from({ length: 64 }, (_unused, index) => assistantMessage(`historical-${index}`, { timestamp: 10_000 + index }), ); const harness = createHarness(); await harness.emit("session_start"); harness.children[0].stdout.emit( "data", 'work', ); await flush(); await harness.emit("agent_start"); await harness.emit("context", { messages: [...highHistory, { role: "user", content: '' }], }); const compactedHistory = [ { role: "user", content: "[auto-compaction summary]" }, highHistory[12], ]; await harness.emit("context", { messages: compactedHistory }); await harness.emit("agent_end", { messages: [ ...compactedHistory, assistantMessage("valid reply after compaction", { timestamp: 20_000 }), ], }); - assert.equal(commandCalls(harness, "send")[0].input, "valid reply after compaction"); + assert.deepEqual(commandCalls(harness, "send"), []); await harness.emit("session_shutdown"); const stale = createHarness(); await stale.emit("session_start"); stale.children[0].stdout.emit( "data", 'work', ); await flush(); await stale.emit("agent_start"); await stale.emit("context", { messages: [...highHistory, { role: "user", content: '' }], }); await stale.emit("context", { messages: [highHistory[63]] }); await stale.emit("agent_end", { messages: [highHistory[63]] }); - const staleOutcome = commandCalls(stale, "send")[0].input; - assert.match(staleOutcome, /turn ended without an assistant response/); - assert.ok(!staleOutcome.includes("historical-63")); + assert.deepEqual(commandCalls(stale, "send"), []); await stale.emit("session_shutdown"); } // [unit->REQ-PARITY-PEER-SHORTFORM] async function testPeerShortformParsingAndDispatch() { assert.deepEqual( parsePeerShortforms("Before @ after @").map( ({ targets, body }) => ({ targets, body }), ), [ { targets: ["alpha", "beta"], body: "hello there" }, { targets: ["gamma"], body: "second\nline" }, ], ); assert.deepEqual( parsePeerShortforms( "ordinary @alice; `@`\n```\n@\n```\n@", ), [], ); const alphaGate = deferred(); const harness = createHarness({ onRun(call) { if (call.args[0] === "send" && call.args[1] === "alpha") return alphaGate.promise; if (call.args[0] === "send" && call.args[1] === "beta") { throw new Error("NO_PERCH:beta"); } if (call.args[0] === "send" && call.args[1] === "gamma") return "QUEUED:gamma"; }, }); await harness.emit("session_start"); await harness.emit("agent_start"); const completion = harness.emit("agent_end", { messages: [ assistantMessage("@\nDone.\n@"), ], }); await flush(); assert.equal( commandCalls(harness, "send").length, 3, "multi-target peer dispatch must not serialize independent recipients", ); alphaGate.resolve("SENT:alpha"); await completion; assert.deepEqual( commandCalls(harness, "send").map((call) => [call.args[1], call.input]), [ ["alpha", "ship the patch"], ["beta", "ship the patch"], ["gamma", "inspect release"], ], ); assert.ok( commandCalls(harness, "send").every((call) => call.options.timeoutMs === 5_000), "peer sends must be bounded independently of the ordinary command timeout", ); assert.equal(harness.sentMessages.length, 1); assert.deepEqual(harness.sentMessages[0].delivery, { deliverAs: "nextTurn", triggerTurn: true, }); assert.match(harness.sentMessages[0].message.content, /alpha: SENT:alpha/); assert.match(harness.sentMessages[0].message.content, /beta: failed \(NO_PERCH:beta\)/); assert.match(harness.sentMessages[0].message.content, /gamma: QUEUED:gamma/); await harness.emit("agent_start"); await harness.emit("agent_end", { messages: [ assistantMessage("@\nDone.\n@"), { role: "user", content: "@" }, assistantMessage("Mention @alpha and quote `@` without side effects."), ], }); assert.equal(commandCalls(harness, "send").length, 3); assert.equal(harness.sentMessages.length, 1); await harness.emit("session_shutdown"); const targetCount = 9; @@ -2201,111 +1838,109 @@ async function testNativeCheckpointTool() { return compactGate.promise; }, onRun(call) { if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { return JSON.stringify({ endpoint_type: "live_agent" }); } }, }); await harness.emit("session_start"); const tool = harness.tools.get("spt_checkpoint"); const checkpoint = tool.execute( "tool-1", { wake: "Continue release preparation." }, undefined, undefined, harness.ctx, ); await flush(); assert.deepEqual(harness.sentMessages, [], "checkpoint wake must wait for native compaction"); compactGate.resolve(); const result = await checkpoint; assert.equal(result.details.ok, true); assert.equal(harness.compactions.length, 1); assert.deepEqual(harness.sentMessages[0], { message: { customType: "omp-spt-checkpoint-wake", content: "Continue release preparation.", display: true, attribution: "user", }, delivery: { deliverAs: "nextTurn", triggerTurn: true }, }); await harness.emit("session_shutdown"); const ready = createHarness({ id: null }); await ready.emit("session_start"); await ready.commands.get("ready").handler("plain-ready", ready.ctx); const refused = await ready.tools .get("spt_checkpoint") .execute("tool-2", {}, undefined, undefined, ready.ctx); assert.equal(refused.isError, true); assert.equal(refused.details.reason, "not-live"); assert.deepEqual(ready.compactions, []); await ready.emit("session_shutdown"); const launchedReady = createHarness({ onRun(call) { if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { return JSON.stringify({ endpoint_type: "ready_agent" }); } }, }); await launchedReady.emit("session_start"); const launchedRefusal = await launchedReady.tools .get("spt_checkpoint") .execute("tool-launch-ready", {}, undefined, undefined, launchedReady.ctx); assert.equal(launchedRefusal.details.reason, "not-live"); assert.deepEqual(launchedReady.compactions, []); await launchedReady.emit("session_shutdown"); const failed = createHarness({ onCompact() { throw new Error("native compaction cancelled"); }, onRun(call) { if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { return JSON.stringify({ endpoint_type: "live_agent" }); } }, }); await failed.emit("session_start"); const failure = await failed.tools .get("spt_checkpoint") .execute("tool-3", {}, undefined, undefined, failed.ctx); assert.equal(failure.isError, true); assert.match(failure.content[0].text, /native compaction cancelled/); assert.deepEqual(failed.sentMessages, [], "a failed reset must never queue a false wake"); await failed.emit("session_shutdown"); } -await testParsingAndReplies(); +await testParsing(); await testRunSptRejectsStdinErrorsAndHungCommands(); await testLifecycleCustodyAndContext(); +await testLocalAssistantOutputDoesNotReplyToPeer(); await testDeferredBindLifecycleSerialization(); -await testOutcomeSendRetriesAndExhaustion(); await testSubmissionFailureAdvancesQueue(); await testFailedIdleRecoveryFailsClosed(); await testListenerRestartExhaustion(); await testListenerStableIntervalResetsRetries(); -await testFatalTeardownAwaitsInFlightOutcome(); await testSessionEndRetriesAfterTransientFailure(); await testHumanBusyFailureFailsClosed(); -await testShutdownReapsAndFailsQueuedCustody(); +await testShutdownReapsAndReleasesQueuedCustody(); await testListenerTerminationEscalatesAndReaps(); await testProtocolCorruptionFailsClosed(); -await testInboundQueueOverflowReturnsAcceptedCustody(); -await testSessionStopAwaitsOutcomeWithoutEndingEndpoint(); +await testInboundQueueOverflowReleasesAcceptedCustody(); await testShutdownFallbackStaysBelowHostCap(); await testNativeActivationCommandsAndErrors(); await testNativeActivationSelectionAndCompletion(); await testPlatformAwareActivationCandidatePaths(); await testExplicitLiveAutoResume(); await testStartupBriefHintsAndUpdateNotices(); await testActiveTurnBoundaryDeliveryAndFallback(); await testAbnormalTurnsRestoreReceivability(); await testCompletionStopReasonGatesSideEffects(); -await testCompactionSafeReplyCorrelation(); +await testCompactionPreservesLocalAssistantOutput(); await testPeerShortformParsingAndDispatch(); await testNativeCheckpointTool(); console.log("OMP-EXTENSION OK"); diff --git a/tools/omp-spt/Cargo.lock b/tools/omp-spt/Cargo.lock index 7258d41..65f6273 100644 --- a/tools/omp-spt/Cargo.lock +++ b/tools/omp-spt/Cargo.lock @@ -1,123 +1,123 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", ] [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "omp-spt" -version = "0.3.4" +version = "0.3.5" dependencies = [ "getrandom", "serde_json", ] [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "syn" version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", "unicode-ident", diff --git a/tools/omp-spt/Cargo.toml b/tools/omp-spt/Cargo.toml index e716bd6..322761c 100644 --- a/tools/omp-spt/Cargo.toml +++ b/tools/omp-spt/Cargo.toml @@ -1,20 +1,20 @@ # OMP-native adapter helper. One dependency-light binary serves launch, # digest/history, Psyche, and echo-commune roles on Windows and Linux. # [impl->REQ-DIST-BINARY-CONSOLIDATE] [package] name = "omp-spt" -version = "0.3.4" +version = "0.3.5" edition = "2021" publish = false [[bin]] # The package and executable share the public adapter identity. name = "omp-spt" path = "src/main.rs" [dependencies] getrandom = "0.3" serde_json = "1" [profile.release] strip = true diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 496dbbd..db7d0b0 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -1,159 +1,159 @@ # OMP-native requirement registry. Evidence roots are intentionally limited to # the retained adapter surface; retired foreign-harness files are not scanned. [scan] roots = [ "OMP-ADAPTER-PLAN.md", "docs/KNOWN-HAZARDS.md", "docs/adr/0007-native-omp-tui-hosts-spt-extension.md", "docs/adr/0008-omp-native-product-boundary.md", "docs/adr/0009-all-endpoints-use-native-omp.md", "docs/adr/0010-native-delivery-self-heals-or-closes.md", "docs/adr/0011-endpoint-session-binding-is-immutable.md", "docs/adr/0012-continuity-drops-live-under-spt.md", "docs/adr/0013-release-gate-stops-at-the-adapter-boundary.md", "docs/PARITY.md", "docs/adr/0015-extension-owned-session-activation.md", "docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md", "docs/adr/0016-agent-capabilities-split-by-native-seam.md", "docs/adr/0018-checkpoint-resets-context-natively.md", "adapter/omp-spt.toml", "adapter/strings/omp-spt.mjs", "adapter/strings/skills", "tools/omp-spt/Cargo.toml", "tools/omp-spt/src/main.rs", "tools/omp-spt/src/digest_omp.rs", "tools/omp-spt/src/history_omp.rs", "tools/omp-spt/src/echo_commune_omp.rs", "tools/omp-spt/src/psyche_omp.rs", "tools/omp-spt/src/launch_omp.rs", "tools/omp-spt/tests/launch_omp.rs", "tools/omp-spt/tests/captured_session_env.rs", "tests/manifest-shortcut.sh", "tests/native-launch-manifest.sh", "tests/omp-extension.mjs", "tests/omp-skills.mjs", "tests/manifest-schema.sh", "tests/adapter-archive.sh", "ci/manifest/check-manifest.sh", "ci/manifest/validate_manifest.py", "ci/publish/package-adapter.sh", "ci/publish/release-acquire-int.sh", "ci/digest/build.sh", "ci/digest/digest-proof-int.sh", "docs/RELEASE-RUNBOOK.md", "docs-site/README.md", "docs-site/src/reference/release-evidence.md", ".github/workflows/docs-pages.yml", "ci/docs/build-docs.py", "ci/release/check-version-consistency.py", "ci/release/validate-release-evidence.py", "ci/run-gates.sh", "tests/docs-gate.py", "tests/release-evidence.py", "tests/version-consistency.py", ] [policy] required_stages = [] [[requirements]] id = "REQ-OMP-NATIVE-TUI" title = "The hosted launch contract hands validated OMP its packaged extension as the broker PTY process" required_stages = ["doc", "impl", "unit", "int"] # Intentionally inactive until an actual release runs the ADR-0013 acceptance gate. Deterministic # fake-process launch evidence must never satisfy this live endpoint requirement. [[requirements]] id = "REQ-OMP-LIVE-RELEASE-GATE" title = "Pending live gate: a real OMP release must satisfy the ADR-0013 endpoint acceptance criteria" required_stages = [] [[requirements]] id = "REQ-OMP-EXECUTABLE-RESOLUTION" title = "Fresh, resumed, and daemon-driven OMP turns resolve and validate Oh My Pi, reject executable collisions, and fail loudly" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-OMP-EXTENSION-CUSTODY" -title = "The OMP extension serializes accepted deliveries and preserves sender custody through a correlated reply or explicit failure" +title = "The OMP extension serializes accepted deliveries while outbound peer messages require an explicit CLI or shortform action" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-LISTENER-FAIL-CLOSED" title = "Unexpected listener exit retries within a finite budget and then performs SPT session-end plus OMP shutdown" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-SESSION-IMMUTABLE" title = "One endpoint owns one OMP session for its lifetime and blocks every in-TUI session-changing action" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-MESSAGE-CONTEXT" title = "Each peer delivery opens one ordinary OMP turn containing a message stub and the complete SPT event context" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-OMP-READY-LIVE" title = "The adapter advertises exactly ReadyAgent and LiveAgent endpoint types on the same native OMP hosting path" required_stages = ["doc", "unit"] [[requirements]] id = "REQ-OMP-CONTINUITY-DROPS" title = "Commune and signoff drops resolve project-locally under .spt on the published v0.29.0 endpoint-cwd contract" required_stages = ["doc", "unit"] [[requirements]] id = "REQ-PSYCHE-EPHEMERAL-SHIM" title = "Each LiveAgent Psyche event runs as one bounded OMP turn with file-backed context and explicit reseed signaling" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-HISTORY-FETCHER" title = "The history fetcher locates exactly one OMP session and streams its JSONL bytes verbatim" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-DIGEST-EXTRACTOR" title = "The OMP digest fetcher maps native session JSONL into the published harness-neutral digest record stream" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-SESSION-ECHO-COMMUNE" title = "The bounded echo-commune role summarizes one OMP session and reports real OMP failures without latching on a locate miss" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-MANIFEST-SCHEMA" title = "The omp-spt manifest validates offline against spt-core's published manifest schema" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-DIST-ADAPTER-RELEASE" title = "One multi-platform adapter.spt ships the manifest, native OMP extension, and omp-spt binary through BigscreenVR/omp-spt releases" required_stages = ["doc", "impl", "unit", "int"] [[requirements]] id = "REQ-DIST-BINARY-CONSOLIDATE" title = "One omp-spt helper binary dispatches only the retained OMP-native launch, digest, history, Psyche, and echo-commune roles" required_stages = ["impl", "unit"] [[requirements]] id = "REQ-PARITY-READY-ACTIVATION" title = "An extension-native command binds an ordinary OMP session as a ready endpoint while the extension owns its listener" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-LIVE-ACTIVATION" title = "An extension-native command binds an ordinary OMP session as a live endpoint with durable Psyche continuity" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-LIVE-AUTO-RESUME" title = "Explicit live auto-resume selects the most-recent compatible live identity without silently guessing on ordinary activation" required_stages = ["doc", "impl", "unit"] [[requirements]] id = "REQ-PARITY-SAFE-BOUNDARY-DELIVERY" title = "Accepted peer messages enter an active OMP turn before its next tool or model continuation, with ordered next-turn fallback" [raw output: artifact://37]