diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs
index 9d2a03c..ba7abfa 100644
--- a/adapter/strings/omp-spt.mjs
+++ b/adapter/strings/omp-spt.mjs
@@ -1,6 +1,20 @@
import { spawn } from "node:child_process";
const ADAPTER = "omp-spt";
+const BUSY_TITLE_GLYPHS = [..."⣾⣽⣻⢿⡿⣟⣯⣷"];
+const IDLE_TITLE_GLYPH = "○";
+const TITLE_FRAME_MS = 80;
+
+// [impl->REQ-OMP-SESSION-TITLES]
+export function endpointDisplayName(id, node, project) {
+ const endpoint = String(id ?? "").trim();
+ const nodeName = String(node ?? "").trim();
+ const projectName = String(project ?? "").trim();
+ if (!nodeName) return endpoint;
+ return projectName
+ ? `${endpoint} @ ${nodeName} (${projectName}/)`
+ : `${endpoint} @ ${nodeName}`;
+}
export function decodeBody(body) {
return body
@@ -587,6 +601,8 @@ export function createOmpSpt(overrides = {}) {
const spawnProcess = overrides.spawnProcess ?? spawn;
const setTimer = overrides.setTimeout ?? globalThis.setTimeout;
const clearTimer = overrides.clearTimeout ?? globalThis.clearTimeout;
+ const setRepeatingTimer = overrides.setInterval ?? globalThis.setInterval;
+ const clearRepeatingTimer = overrides.clearInterval ?? globalThis.clearInterval;
const env = overrides.env ?? process.env;
const platform = overrides.platform ?? process.platform;
const killGraceMs = overrides.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
@@ -763,6 +779,32 @@ export function createOmpSpt(overrides = {}) {
let current;
let stopping = false;
let ui;
+ let titleTimer;
+ let titleFrame = 0;
+ const displayName = () =>
+ endpointDisplayName(id, env.OMP_SPT_NODE, env.OMP_SPT_PROJECT);
+ const setWindowTitle = (glyph) => ui?.setTitle(`${glyph} ${displayName()}`);
+ const stopTitleAnimation = () => {
+ if (titleTimer !== undefined) {
+ clearRepeatingTimer(titleTimer);
+ titleTimer = undefined;
+ }
+ titleFrame = 0;
+ };
+ const showIdleTitle = () => {
+ stopTitleAnimation();
+ setWindowTitle(IDLE_TITLE_GLYPH);
+ };
+ const showBusyTitle = () => {
+ stopTitleAnimation();
+ setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]);
+ titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length;
+ titleTimer = setRepeatingTimer(() => {
+ setWindowTitle(BUSY_TITLE_GLYPHS[titleFrame]);
+ titleFrame = (titleFrame + 1) % BUSY_TITLE_GLYPHS.length;
+ }, TITLE_FRAME_MS);
+ titleTimer?.unref?.();
+ };
let runtimeCtx;
let endpointState;
let stateOperation = Promise.resolve();
@@ -1131,6 +1173,9 @@ export function createOmpSpt(overrides = {}) {
activated = true;
startupBriefPending = true;
ui.setStatus("omp-spt", `spt:${id}`);
+ // [impl->REQ-OMP-SESSION-TITLES]
+ pi.setSessionName(displayName());
+ showIdleTitle();
startListener();
beginUpdateProbe();
if (options.announce) {
@@ -1432,6 +1477,7 @@ export function createOmpSpt(overrides = {}) {
}
async function stopResources() {
+ stopTitleAnimation();
if (dispatchTimer !== undefined) {
clearTimer(dispatchTimer);
dispatchTimer = undefined;
@@ -1867,6 +1913,7 @@ export function createOmpSpt(overrides = {}) {
// [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY]
async function completeTurn(event) {
agentActive = false;
+ showIdleTitle();
desiredState = "idle";
if (stopping) return;
const messages = event.messages ?? [];
@@ -1897,6 +1944,7 @@ export function createOmpSpt(overrides = {}) {
turnAssistantBaseline = observedAssistantBaseline;
turnContextObserved = false;
agentActive = true;
+ showBusyTitle();
desiredState = "busy";
try {
await syncDesiredState();
diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs
index 4adfcf5..5af4c92 100644
--- a/tests/omp-extension.mjs
+++ b/tests/omp-extension.mjs
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
createOmpSpt,
+ endpointDisplayName,
decodeBody,
drainEvents,
parsePeerShortforms,
@@ -117,6 +118,9 @@ function createHarness(options = {}) {
const selections = [];
const confirmations = [];
const inputs = [];
+ const titles = [];
+ const sessionNames = [];
+ const intervals = [];
const compactions = [];
const clock = new FakeClock();
let shutdowns = 0;
@@ -145,6 +149,9 @@ function createHarness(options = {}) {
setStatus(key, text) {
statuses.push({ key, text });
},
+ setTitle(title) {
+ titles.push(title);
+ },
async select(title, values) {
selections.push({ title, values });
return options.onSelect?.(title, values) ?? options.selectResults?.shift();
@@ -173,6 +180,9 @@ function createHarness(options = {}) {
};
const pi = {
zod: { z },
+ setSessionName(name) {
+ sessionNames.push(name);
+ },
logger: {
error(message, details) {
errors.push({ message, details });
@@ -226,6 +236,8 @@ function createHarness(options = {}) {
SPT_ENDPOINT_ID: Object.hasOwn(options, "id") ? options.id : "omp-agent",
OMP_SPT_SUBNET: options.subnet,
OMP_SPT_SPT_BIN: "spt-test",
+ OMP_SPT_NODE: options.node,
+ OMP_SPT_PROJECT: options.project,
},
checkUpdates: options.checkUpdates ?? false,
fetchLatestAdapterVersion: options.fetchLatestAdapterVersion,
@@ -245,6 +257,19 @@ function createHarness(options = {}) {
spawnProcess,
setTimeout: clock.setTimeout.bind(clock),
clearTimeout: clock.clearTimeout.bind(clock),
+ setInterval(fn, delay) {
+ const interval = {
+ active: true,
+ delay,
+ fn,
+ unref() {},
+ };
+ intervals.push(interval);
+ return interval;
+ },
+ clearInterval(interval) {
+ interval.active = false;
+ },
});
extension(pi);
@@ -274,6 +299,9 @@ function createHarness(options = {}) {
selections,
sentMessages,
statuses,
+ intervals,
+ sessionNames,
+ titles,
submitted,
tools,
get shutdowns() {
@@ -300,6 +328,13 @@ function assertNoAgentManagedPoll(harness) {
async function testParsing() {
assert.equal(decodeBody('a<b>
"c"
legacy & <'), 'a\n"c"\nlegacy & <');
+ // [unit->REQ-OMP-SESSION-TITLES]
+ assert.equal(
+ endpointDisplayName("emphasys", "HFENDULEAM", "omp-spt"),
+ "emphasys @ HFENDULEAM (omp-spt/)",
+ );
+ assert.equal(endpointDisplayName("emphasys", "HFENDULEAM"), "emphasys @ HFENDULEAM");
+ assert.equal(endpointDisplayName("emphasys", undefined, "omp-spt"), "emphasys");
const partialEnvelope = 'hello
wo';
const partial = drainEvents(`noise${partialEnvelope}`);
@@ -346,6 +381,31 @@ async function testParsing() {
}
+// [unit->REQ-OMP-SESSION-TITLES]
+async function testEndpointSessionNameAndAnimatedWindowTitle() {
+ const harness = createHarness({
+ id: "emphasys",
+ node: "HFENDULEAM",
+ project: "omp-spt",
+ });
+ await harness.emit("session_start");
+ assert.deepEqual(harness.sessionNames, ["emphasys @ HFENDULEAM (omp-spt/)"]);
+ assert.deepEqual(harness.titles, ["○ emphasys @ HFENDULEAM (omp-spt/)"]);
+
+ await harness.emit("agent_start");
+ assert.equal(harness.titles.at(-1), "⣾ emphasys @ HFENDULEAM (omp-spt/)");
+ assert.equal(harness.intervals.length, 1);
+ assert.equal(harness.intervals[0].delay, 80);
+ harness.intervals[0].fn();
+ assert.equal(harness.titles.at(-1), "⣽ emphasys @ HFENDULEAM (omp-spt/)");
+
+ await harness.emit("agent_end", { messages: [] });
+ assert.equal(harness.intervals[0].active, false);
+ assert.equal(harness.titles.at(-1), "○ emphasys @ HFENDULEAM (omp-spt/)");
+ await harness.emit("session_shutdown");
+ assert.equal(harness.intervals[0].active, false);
+}
+
// [unit->REQ-OMP-EXTENSION-CUSTODY]
// [unit->REQ-OMP-SESSION-IMMUTABLE]
// [unit->REQ-OMP-MESSAGE-CONTEXT]
@@ -1916,6 +1976,7 @@ async function testNativeCheckpointTool() {
}
await testParsing();
+await testEndpointSessionNameAndAnimatedWindowTitle();
await testRunSptRejectsStdinErrorsAndHungCommands();
await testLifecycleCustodyAndContext();
await testLocalAssistantOutputDoesNotReplyToPeer();
diff --git a/tools/omp-spt/src/launch_omp.rs b/tools/omp-spt/src/launch_omp.rs
index bf47cd7..bd58919 100644
--- a/tools/omp-spt/src/launch_omp.rs
+++ b/tools/omp-spt/src/launch_omp.rs
@@ -12,6 +12,8 @@ use std::process::{Command, ExitCode, Stdio};
use std::time::{Duration, Instant};
const OMP_BIN_ENV: &str = "OMP_SPT_OMP_BIN";
+const OMP_NODE_ENV: &str = "OMP_SPT_NODE";
+const OMP_PROJECT_ENV: &str = "OMP_SPT_PROJECT";
const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, PartialEq)]
@@ -19,11 +21,12 @@ struct Args {
id: String,
extension: String,
resume: Option,
+ node: Option,
}
impl Args {
fn parse>(argv: I) -> Result {
- let (mut id, mut extension, mut resume) = (None, None, None);
+ let (mut id, mut extension, mut resume, mut node) = (None, None, None, None);
let mut it = argv.into_iter();
while let Some(flag) = it.next() {
let value = it.next().ok_or_else(|| format!("{flag} expects a value"))?;
@@ -31,7 +34,8 @@ impl Args {
"--id" if id.is_none() => id = Some(value),
"--extension" if extension.is_none() => extension = Some(value),
"--resume" if resume.is_none() => resume = Some(value),
- "--id" | "--extension" | "--resume" => {
+ "--node" if node.is_none() => node = Some(value),
+ "--id" | "--extension" | "--resume" | "--node" => {
return Err(format!("duplicate arg: {flag}"))
}
other => return Err(format!("unknown arg: {other}")),
@@ -41,6 +45,9 @@ impl Args {
id: id.ok_or("missing --id")?,
extension: extension.ok_or("missing --extension")?,
resume,
+ node: node
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty() && value != "{node}"),
})
}
}
@@ -58,6 +65,22 @@ fn omp_argv(args: &Args) -> Vec {
argv
}
+// [impl->REQ-OMP-SESSION-TITLES]
+fn project_name(project_root: &Path) -> Option {
+ let name = project_root.file_name()?.to_string_lossy().trim().to_string();
+ (!name.is_empty()).then_some(name)
+}
+
+// [impl->REQ-OMP-SESSION-TITLES]
+fn apply_identity_env(command: &mut Command, args: &Args, project_root: &Path) {
+ if let Some(node) = &args.node {
+ command.env(OMP_NODE_ENV, node);
+ }
+ if let Some(project) = project_name(project_root) {
+ command.env(OMP_PROJECT_ENV, project);
+ }
+}
+
fn known_install_locations(home: Option<&OsStr>, local_app_data: Option<&OsStr>) -> Vec {
let mut paths = Vec::with_capacity(4);
#[cfg(windows)]
@@ -437,6 +460,7 @@ pub fn run() -> ExitCode {
}
let mut command = Command::new(&program);
+ apply_identity_env(&mut command, &args, &project_root);
command
.env(OMP_BIN_ENV, &program)
.args(omp_argv(&args))
@@ -524,6 +548,53 @@ mod tests {
);
}
+ // [unit->REQ-OMP-SESSION-TITLES]
+ #[test]
+ fn advertised_node_and_project_are_forwarded_to_the_extension() {
+ let parsed = args(&[
+ "--id",
+ "agent-1",
+ "--node",
+ "HFENDULEAM",
+ "--extension",
+ "omp-spt.mjs",
+ ])
+ .unwrap();
+ let mut command = Command::new("omp");
+ apply_identity_env(
+ &mut command,
+ &parsed,
+ Path::new("C:/projects/omp-spt"),
+ );
+ let env = command
+ .get_envs()
+ .map(|(name, value)| {
+ (
+ name.to_string_lossy().into_owned(),
+ value.map(|value| value.to_string_lossy().into_owned()),
+ )
+ })
+ .collect::>();
+ assert_eq!(env.get(OMP_NODE_ENV), Some(&Some("HFENDULEAM".into())));
+ assert_eq!(env.get(OMP_PROJECT_ENV), Some(&Some("omp-spt".into())));
+ }
+
+ #[test]
+ fn absent_identity_values_are_not_forwarded() {
+ let parsed = args(&[
+ "--id",
+ "agent-1",
+ "--node",
+ "{node}",
+ "--extension",
+ "omp-spt.mjs",
+ ])
+ .unwrap();
+ let mut command = Command::new("omp");
+ apply_identity_env(&mut command, &parsed, Path::new("/"));
+ assert_eq!(command.get_envs().count(), 0);
+ }
+
#[test]
fn parser_rejects_incomplete_ambiguous_or_unknown_input() {
assert!(args(&[]).unwrap_err().contains("missing --id"));