@@ -438,6 +438,23 @@ enum AgentCmd {
438438 /// The old address stops resolving as soon as the new catalog generation is visible and may be
439439 /// claimed by another subject. There is no alias, redirect, history, or expiry.
440440 Address ( AddressArgs ) ,
441+ /// Attach this terminal to a running agent's canonical PTY session.
442+ ///
443+ /// Resolves the agent's canonical `agent` PTY task, waits (bounded) for positive evidence that
444+ /// its session is live, then **replaces** this process with `pty attach --force <runtime-id>`.
445+ /// From that point `pty` owns the tty, signals, detach keys, and the exit status; st2 adds
446+ /// nothing to the session itself.
447+ Attach {
448+ /// Exact catalog-global agent ID. Never parsed as an address.
449+ #[ arg( long, value_name = "AGENT-ID" ) ]
450+ id : String ,
451+ /// Host used to resolve declarations whose host is omitted. Defaults to the local hostname.
452+ #[ arg( long) ]
453+ host : Option < String > ,
454+ /// Seconds to wait for the session to become live before giving up.
455+ #[ arg( long, value_name = "SECONDS" ) ]
456+ wait : Option < u64 > ,
457+ } ,
441458 /// Author reversible whole-agent lifecycle intent in one canonical KDL declaration.
442459 DesiredState {
443460 /// Exact catalog-global agent ID of the declaration to edit.
@@ -1416,6 +1433,7 @@ fn dispatch(command: Command, catalog_path: Option<&std::path::Path>) -> Result<
14161433 presentation_cmd ( st2:: agent_author:: PresentationField :: Description , args)
14171434 }
14181435 Command :: Agent ( AgentCmd :: Address ( args) ) => address_cmd ( args) ,
1436+ Command :: Agent ( AgentCmd :: Attach { id, host, wait } ) => attach_cmd ( & id, host, wait) ,
14191437 Command :: Agent ( AgentCmd :: DesiredState {
14201438 id,
14211439 state,
@@ -2109,6 +2127,117 @@ fn pty_cmd(args: &[String]) -> Result<()> {
21092127 Err ( anyhow:: anyhow!( "failed to exec `pty`: {err}" ) )
21102128}
21112129
2130+ /// How long `st2 agent attach` waits for positive evidence that the target session is live. st2
2131+ /// owns this bound so callers that just spawned an agent (`new-agent`) need no flag at all.
2132+ const ATTACH_READY_WAIT : Duration = Duration :: from_secs ( 30 ) ;
2133+
2134+ /// Readiness poll cadence. A pidfile read plus `kill(pid, 0)` is cheap enough to check often, and
2135+ /// the loop is deadline-clamped so the last sleep never overshoots `--wait`.
2136+ const ATTACH_READY_POLL : Duration = Duration :: from_millis ( 100 ) ;
2137+
2138+ /// `st2 agent attach --id <AGENT-ID>` — hand this terminal to a running agent's canonical PTY.
2139+ ///
2140+ /// Selection is exact-ID only: an address must never resolve here, because attaching to the wrong
2141+ /// live session is a destructive mistake a route cutover could otherwise cause. st2's whole added
2142+ /// surface is resolve → wait → exec: it derives the canonical `agent` task's runtime id from the
2143+ /// declaration (never guesses it), waits for *positive* liveness evidence, then **replaces** this
2144+ /// process with `pty attach --force <runtime-id>`. Everything terminal-shaped — signals, detach,
2145+ /// restart prompting, exit status — is `pty`'s from that instant on. A wait timeout is a human
2146+ /// diagnostic only; nothing is authored, recorded, or retried.
2147+ fn attach_cmd ( id : & str , host : Option < String > , wait : Option < u64 > ) -> Result < ( ) > {
2148+ use std:: os:: unix:: process:: CommandExt ;
2149+
2150+ let root = catalog_root_for_env ( ) ?;
2151+ let host = host. unwrap_or_else ( detect_host) ;
2152+ let wait = wait. map ( Duration :: from_secs) . unwrap_or ( ATTACH_READY_WAIT ) ;
2153+
2154+ let runtime_id = {
2155+ let _catalog_lock = st2:: CatalogLock :: shared ( & root)
2156+ . context ( "acquire shared catalog-authoring lock for agent attach" ) ?;
2157+ let found = st2:: discover_strict ( & root) ;
2158+ // Attaching selects one exact declaration; a partially readable catalog could hide the very
2159+ // subject named, so refuse rather than attach to whatever did parse.
2160+ if !found. errors . is_empty ( ) {
2161+ let errors = found
2162+ . errors
2163+ . iter ( )
2164+ . map ( |error| format ! ( "{}: {}" , error. path. display( ) , error. message) )
2165+ . collect :: < Vec < _ > > ( )
2166+ . join ( "; " ) ;
2167+ anyhow:: bail!(
2168+ "cannot attach to an exact Agent Spec while catalog discovery has {} error(s): {errors}" ,
2169+ found. errors. len( )
2170+ ) ;
2171+ }
2172+ let spec = resolve_agent_spec ( & found, & st2:: AgentSelector :: id ( id) , & host) ?;
2173+ let agent_id = spec. agent_id ( & host) ;
2174+ let spec_host = spec. resolved_host ( & host) ;
2175+ anyhow:: ensure!(
2176+ spec_host == host,
2177+ "agent '{agent_id}' is homed on host '{spec_host}'; its pty registry is not observable \
2178+ from '{host}' — attach from that host (or pass --host '{spec_host}' there)"
2179+ ) ;
2180+ let mut candidates = spec
2181+ . tasks
2182+ . iter ( )
2183+ . filter ( |task| !task. derived && task. name == "agent" ) ;
2184+ let task = candidates
2185+ . next ( )
2186+ . with_context ( || format ! ( "agent '{agent_id}' has no canonical `agent` task" ) ) ?;
2187+ anyhow:: ensure!(
2188+ candidates. next( ) . is_none( ) ,
2189+ "agent '{agent_id}' has more than one canonical `agent` task"
2190+ ) ;
2191+ anyhow:: ensure!(
2192+ task. kind == st2:: TaskKind :: Pty ,
2193+ "agent '{agent_id}' canonical task is not a PTY; there is no terminal to attach to"
2194+ ) ;
2195+ st2:: reconcile:: task_runtime_id ( spec, task, & host)
2196+ } ;
2197+
2198+ // The catalog's own registry, exactly as the runner resolves it. This one value is both probed
2199+ // for liveness AND handed to `pty` below, so the session st2 proved alive is provably the
2200+ // session `pty` then looks for.
2201+ let pty_root = st2:: agents:: probe_pty_root ( & root) ;
2202+ // A `--wait` large enough to overflow the monotonic clock is a typo, not a request for an
2203+ // unbounded wait: refuse with the bound named rather than panicking inside the runtime.
2204+ let deadline = std:: time:: Instant :: now ( ) . checked_add ( wait) . with_context ( || {
2205+ format ! (
2206+ "--wait {}s overflows the monotonic clock; pass a bound this machine can represent" ,
2207+ wait. as_secs( )
2208+ )
2209+ } ) ?;
2210+ loop {
2211+ if ding:: session_liveness_in ( & pty_root, & runtime_id) == st2:: harness_state:: SessionLiveness :: Alive
2212+ {
2213+ break ;
2214+ }
2215+ let now = std:: time:: Instant :: now ( ) ;
2216+ if now >= deadline {
2217+ anyhow:: bail!(
2218+ "pty session '{runtime_id}' is not live after {}s; nothing was attached — check \
2219+ `st2 tasks --json` and run `st2 up` if the agent is not running",
2220+ wait. as_secs( )
2221+ ) ;
2222+ }
2223+ std:: thread:: sleep ( deadline. saturating_duration_since ( now) . min ( ATTACH_READY_POLL ) ) ;
2224+ }
2225+
2226+ let mut cmd = std:: process:: Command :: new ( "pty" ) ;
2227+ // `--force`: the caller is themselves inside a pty session, which `pty attach` otherwise
2228+ // refuses to nest. `--no-restart` is deliberately not passed — pty owns restart semantics.
2229+ cmd. args ( [ "attach" , "--force" , & runtime_id] ) ;
2230+ with_bus_env ( & mut cmd, & root) ;
2231+ // `with_bus_env` renders PTY_ROOT from the catalog declaration alone, while the liveness probe
2232+ // resolves it the way the *runner* does — an ambient PTY_ROOT outranks the declaration there.
2233+ // Pin the probed root so the two can never disagree: otherwise a caller with an exported
2234+ // PTY_ROOT would have st2 prove one session alive and hand `pty` a different registry.
2235+ cmd. env ( "PTY_ROOT" , & pty_root) ;
2236+ // exec() only returns on failure (e.g. `pty` not on PATH).
2237+ let err = cmd. exec ( ) ;
2238+ Err ( anyhow:: anyhow!( "failed to exec `pty`: {err}" ) )
2239+ }
2240+
21122241/// `st2 shell [<args>…]` — drop into `$SHELL` with the native catalog environment set. The general
21132242/// form of `st2 pty`.
21142243fn shell_cmd ( args : & [ String ] ) -> Result < ( ) > {
0 commit comments