diff --git a/adapter/omp-spt.toml b/adapter/omp-spt.toml index 9ac6321..66ba1fc 100644 --- a/adapter/omp-spt.toml +++ b/adapter/omp-spt.toml @@ -68,18 +68,19 @@ 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. +# under the endpoint project's `.spt` before OMP takes over the broker PTY. It +# also forwards the daemon-advertised node label for operator-facing naming. [session.self] -# [impl->REQ-OMP-EXECUTABLE-RESOLUTION] -command = "{adapter_dir}/omp-spt launch-omp --id {id} --extension {adapter_dir}/strings/omp-spt.mjs" -keys = ["id"] +# [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] +command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --extension {adapter_dir}/strings/omp-spt.mjs" +keys = ["id", "node"] # Resume refreshes the same endpoint snapshot, then uses OMP's native session -# selector with the packaged extension. +# selector with the packaged extension and the same naming inputs. [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"] +# [impl->REQ-OMP-EXECUTABLE-RESOLUTION] [impl->REQ-OMP-SESSION-TITLES] +command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs" +keys = ["id", "node", "session_id"] # The bounded summarizer reads the selected OMP session JSONL and runs one # extension-free OMP turn. A missing transcript is an empty delta; a real OMP 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/docs-site/llms-full.txt b/docs-site/llms-full.txt index 5799083..6c14c2c 100644 --- a/docs-site/llms-full.txt +++ b/docs-site/llms-full.txt @@ -50,10 +50,12 @@ 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, while ordinary -assistant output remains in the local conversation. Sending to another endpoint requires explicit -`spt send` use or the `@<…@>` shortform. The endpoint moves between busy and idle as turns run. -When the TUI shuts down, the extension ends the bound session and releases its listener. +endpoint and starts message delivery. It names the session ` @ +(/)` and gives the terminal title an idle glyph or animated busy spinner, making a +fleet of OMP windows identifiable at a glance. Incoming messages are queued into OMP turns, while +ordinary assistant output remains in the local conversation. Sending to another endpoint requires +explicit `spt send` use or the `@<…@>` shortform. 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. @@ -206,7 +208,13 @@ default. The command needs no separate start flag. For a fresh endpoint, the resolved manifest starts the launch helper with the packaged extension. For an existing endpoint, the resume path selects the recorded OMP session and loads the same -extension. Both paths leave the user in OMP's native TUI. +extension. Both paths leave the user in OMP's native TUI and carry the same endpoint naming inputs. + + +The OMP session name is ` @ (/)`. The terminal window adds a +leading status glyph: `○` while idle and an animated braille spinner while busy. If the project +cannot be determined, only its suffix is omitted; if the node is unavailable, the stable name +degrades to the bare endpoint id. The glyph is never persisted in the session name. ## Extension lifecycle @@ -214,12 +222,12 @@ The extension receives the endpoint id from `spt-core` and responds to native OM | OMP lifecycle point | Adapter action | | --- | --- | -| Session start | Bind the endpoint to OMP's session id, retain the returned authentication token, mark the endpoint idle, and start its delivery listener. | +| Session start | Bind the endpoint to OMP's session id, retain the returned authentication token, apply the stable session name and idle window title, mark the endpoint idle, and start its delivery listener. | | Incoming Spacetime message | Parse the self-delimiting message envelope, retain its sender, queue it, and submit it to OMP when no other agent turn is active. | | Context assembly | Preserve the complete message envelope in the OMP turn so sender and body remain available to the model. | -| Agent start | Mark the endpoint busy. | -| Agent end | Release the completed delivery, mark the endpoint idle, dispatch explicit `@<…@>` peer messages if present, and advance the queue. Ordinary assistant output remains local. | -| Session shutdown | Stop delivery and retry timers, release pending messages without synthesizing outbound text, end the bound session, and clear the OMP status indicator. | +| Agent start | Mark the endpoint busy and animate the terminal-title glyph. | +| Agent end | Release the completed delivery, restore the idle title, mark the endpoint idle, dispatch explicit `@<…@>` peer messages if present, and advance the queue. Ordinary assistant output remains local. | +| Session shutdown | Stop title, delivery, and retry timers; release pending messages without synthesizing outbound text; end the bound session; and clear the OMP status indicator. | Messages are processed one at a time in arrival order. A message received during an active turn waits in the extension queue rather than interrupting that turn. Assistant prose, errors, and diff --git a/docs-site/src/introduction.md b/docs-site/src/introduction.md index 0ad859b..79f2303 100644 --- a/docs-site/src/introduction.md +++ b/docs-site/src/introduction.md @@ -41,10 +41,12 @@ 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, while ordinary -assistant output remains in the local conversation. Sending to another endpoint requires explicit -`spt send` use or the `@<…@>` shortform. The endpoint moves between busy and idle as turns run. -When the TUI shuts down, the extension ends the bound session and releases its listener. +endpoint and starts message delivery. It names the session ` @ +(/)` and gives the terminal title an idle glyph or animated busy spinner, making a +fleet of OMP windows identifiable at a glance. Incoming messages are queued into OMP turns, while +ordinary assistant output remains in the local conversation. Sending to another endpoint requires +explicit `spt send` use or the `@<…@>` shortform. 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. diff --git a/docs-site/src/reference/harness-contract.md b/docs-site/src/reference/harness-contract.md index a3f54cf..ae9e93a 100644 --- a/docs-site/src/reference/harness-contract.md +++ b/docs-site/src/reference/harness-contract.md @@ -39,7 +39,13 @@ default. The command needs no separate start flag. For a fresh endpoint, the resolved manifest starts the launch helper with the packaged extension. For an existing endpoint, the resume path selects the recorded OMP session and loads the same -extension. Both paths leave the user in OMP's native TUI. +extension. Both paths leave the user in OMP's native TUI and carry the same endpoint naming inputs. + + +The OMP session name is ` @ (/)`. The terminal window adds a +leading status glyph: `○` while idle and an animated braille spinner while busy. If the project +cannot be determined, only its suffix is omitted; if the node is unavailable, the stable name +degrades to the bare endpoint id. The glyph is never persisted in the session name. ## Extension lifecycle @@ -47,12 +53,12 @@ The extension receives the endpoint id from `spt-core` and responds to native OM | OMP lifecycle point | Adapter action | | --- | --- | -| Session start | Bind the endpoint to OMP's session id, retain the returned authentication token, mark the endpoint idle, and start its delivery listener. | +| Session start | Bind the endpoint to OMP's session id, retain the returned authentication token, apply the stable session name and idle window title, mark the endpoint idle, and start its delivery listener. | | Incoming Spacetime message | Parse the self-delimiting message envelope, retain its sender, queue it, and submit it to OMP when no other agent turn is active. | | Context assembly | Preserve the complete message envelope in the OMP turn so sender and body remain available to the model. | -| Agent start | Mark the endpoint busy. | -| Agent end | Release the completed delivery, mark the endpoint idle, dispatch explicit `@<…@>` peer messages if present, and advance the queue. Ordinary assistant output remains local. | -| Session shutdown | Stop delivery and retry timers, release pending messages without synthesizing outbound text, end the bound session, and clear the OMP status indicator. | +| Agent start | Mark the endpoint busy and animate the terminal-title glyph. | +| Agent end | Release the completed delivery, restore the idle title, mark the endpoint idle, dispatch explicit `@<…@>` peer messages if present, and advance the queue. Ordinary assistant output remains local. | +| Session shutdown | Stop title, delivery, and retry timers; release pending messages without synthesizing outbound text; end the bound session; and clear the OMP status indicator. | Messages are processed one at a time in arrival order. A message received during an active turn waits in the extension queue rather than interrupting that turn. Assistant prose, errors, and diff --git a/docs/CI.md b/docs/CI.md index 28089f0..beedb66 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -71,13 +71,15 @@ printf 'After reading this message, explicitly send exactly OMP-SPT-ACCEPTED to 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 `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. +- ordinary assistant prose visible in the TUI is not forwarded to the sender; +- `spt endpoint list --json` shows the endpoint move from idle to busy for the turn and back to idle after completion; and +- the OMP session name is ` @ (/)`, the idle window title begins with `○`, and the busy turn visibly cycles braille spinner glyphs before returning to `○`. 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. diff --git a/docs/PARITY.md b/docs/PARITY.md index a175443..4971697 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -31,6 +31,11 @@ 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. + +Hosted OMP matches the sibling session-name shape, ` @ +(/)`, and improves fleet scanning with an idle glyph or animated +busy spinner in the terminal window title. + 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 strict separation between local assistant diff --git a/tests/manifest-shortcut.sh b/tests/manifest-shortcut.sh index 0c943a0..011f670 100644 --- a/tests/manifest-shortcut.sh +++ b/tests/manifest-shortcut.sh @@ -30,17 +30,17 @@ case "$hosts" in esac # ── bringup: launch shim resolves OMP, then native OMP owns the broker PTY ──────────────────────── -# [unit->REQ-OMP-NATIVE-TUI] +# [unit->REQ-OMP-NATIVE-TUI] [unit->REQ-OMP-SESSION-TITLES] self_spawn=$(field_of session.self '^[[:space:]]*command[[:space:]]*=') resume_spawn=$(field_of session.resume '^[[:space:]]*command[[:space:]]*=') case "$self_spawn" in - 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --extension {adapter_dir}/strings/omp-spt.mjs"') - echo "ok [session.self] snapshots the endpoint env before native OMP launch" ;; + 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --extension {adapter_dir}/strings/omp-spt.mjs"') + echo "ok [session.self] snapshots endpoint env and forwards naming inputs before native OMP launch" ;; *) echo "FAIL [session.self] does not use the endpoint-aware native OMP launch shim: $self_spawn"; fail=1 ;; esac case "$resume_spawn" in - 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs"') - echo "ok [session.resume] refreshes the endpoint snapshot before native resume" ;; + 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs"') + echo "ok [session.resume] refreshes endpoint snapshot and naming inputs before native resume" ;; *) echo "FAIL [session.resume] does not use endpoint-aware native resume: $resume_spawn"; fail=1 ;; esac spawn="$self_spawn diff --git a/tests/native-launch-manifest.sh b/tests/native-launch-manifest.sh index 60c7060..6eb4b0a 100644 --- a/tests/native-launch-manifest.sh +++ b/tests/native-launch-manifest.sh @@ -17,18 +17,18 @@ expect() { fi } -# [unit->REQ-OMP-NATIVE-TUI] [unit->REQ-OMP-EXECUTABLE-RESOLUTION] -expect "[session.self] uses launch-omp with endpoint snapshot key" \ - 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --extension {adapter_dir}/strings/omp-spt.mjs"' \ +# [unit->REQ-OMP-NATIVE-TUI] [unit->REQ-OMP-EXECUTABLE-RESOLUTION] [unit->REQ-OMP-SESSION-TITLES] +expect "[session.self] uses launch-omp with endpoint and node naming keys" \ + 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --extension {adapter_dir}/strings/omp-spt.mjs"' \ "$(field_of session.self '^[[:space:]]*command[[:space:]]*=')" -expect "[session.self] declares endpoint id fill" \ - 'keys = ["id"]' \ +expect "[session.self] declares endpoint and node fills" \ + 'keys = ["id", "node"]' \ "$(field_of session.self '^[[:space:]]*keys[[:space:]]*=')" -expect "[session.resume] forwards endpoint + native resume ids" \ - 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs"' \ +expect "[session.resume] forwards endpoint, node, and native resume ids" \ + 'command = "{adapter_dir}/omp-spt launch-omp --id {id} --node {node} --resume {session_id} --extension {adapter_dir}/strings/omp-spt.mjs"' \ "$(field_of session.resume '^[[:space:]]*command[[:space:]]*=')" -expect "[session.resume] declares endpoint + session fills" \ - 'keys = ["id", "session_id"]' \ +expect "[session.resume] declares endpoint, node, and session fills" \ + 'keys = ["id", "node", "session_id"]' \ "$(field_of session.resume '^[[:space:]]*keys[[:space:]]*=')" # [unit->REQ-OMP-READY-LIVE] expect "hostable_types is ReadyAgent + LiveAgent only" \ 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")); diff --git a/traceable-reqs.toml b/traceable-reqs.toml index db7d0b0..41f26d8 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -89,6 +89,11 @@ 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-SESSION-TITLES" +title = "Hosted OMP sessions use endpoint, node, and project names while the terminal title visibly tracks idle and animated busy state" +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"