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 & &lt;'), '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"));