@@ -208,13 +208,47 @@ enum Command {
208208 } ,
209209 /// Report whether each agent's hooks are wired up.
210210 Doctor ,
211+ /// Drive a live session from a script, without taking the TUI seat.
212+ Ctl {
213+ /// Session name as `p2pmux list` prints it. Omit it for the fleet
214+ /// session when this machine is paired, otherwise the newest live one.
215+ #[ arg( long) ]
216+ session : Option < String > ,
217+ #[ command( subcommand) ]
218+ action : CtlCommand ,
219+ } ,
211220 #[ command( name = "__node" , hide = true ) ]
212221 Node {
213222 #[ arg( long) ]
214223 bootstrap : std:: path:: PathBuf ,
215224 } ,
216225}
217226
227+ #[ derive( Debug , Subcommand ) ]
228+ enum CtlCommand {
229+ /// Pairing-owned machines this session can start work on.
230+ Machines ,
231+ /// The same agent list Ctrl+O would show.
232+ Agents ,
233+ /// Start an allowlisted command on a machine you own.
234+ Spawn {
235+ #[ arg( long) ]
236+ machine : String ,
237+ #[ arg( trailing_var_arg = true , allow_hyphen_values = true ) ]
238+ command : Vec < String > ,
239+ } ,
240+ /// Write bytes into a pane as if they were typed.
241+ Send {
242+ pane : String ,
243+ #[ arg( trailing_var_arg = true , allow_hyphen_values = true ) ]
244+ keys : Vec < String > ,
245+ } ,
246+ /// Move this node's focus to an agent that already has a pane.
247+ Focus { agent : String } ,
248+ /// Stream needs_you / done / error as JSON lines.
249+ Events ,
250+ }
251+
218252#[ derive( Debug , Subcommand ) ]
219253enum SetupAgent {
220254 /// Claude Code, via marker-owned entries in ~/.claude/settings.json.
@@ -411,6 +445,7 @@ pub fn run_without_runtime(cli: &Cli) -> Option<Result<(), Box<dyn Error>>> {
411445 None => crate :: agent_setup:: setup_all ( false , false ) ,
412446 } ) ,
413447 Some ( Command :: Doctor ) => Some ( crate :: agent_setup:: doctor ( ) ) ,
448+ Some ( Command :: Ctl { session, action } ) => Some ( run_ctl ( session. as_deref ( ) , action) ) ,
414449 // Reading and writing one small file. A user asking what is being sent
415450 // about them wants the answer now, not after a thread pool starts.
416451 Some ( Command :: Telemetry { command } ) => Some ( run_telemetry ( command. as_ref ( ) ) ) ,
@@ -461,6 +496,52 @@ fn run_telemetry(command: Option<&TelemetryCommand>) -> Result<(), Box<dyn Error
461496 Ok ( ( ) )
462497}
463498
499+ /// Talk to a live node without taking its TUI seat, and without starting one.
500+ fn run_ctl ( session : Option < & str > , action : & CtlCommand ) -> Result < ( ) , Box < dyn Error > > {
501+ let descriptor = resolve_ctl_session ( session) ?;
502+ let action = match action {
503+ CtlCommand :: Machines => crate :: ctl:: CtlAction :: Machines ,
504+ CtlCommand :: Agents => crate :: ctl:: CtlAction :: Agents ,
505+ CtlCommand :: Spawn { machine, command } => crate :: ctl:: CtlAction :: Spawn {
506+ machine : machine. clone ( ) ,
507+ command : command. clone ( ) ,
508+ } ,
509+ CtlCommand :: Send { pane, keys } => {
510+ let pane_id = pane
511+ . parse :: < u64 > ( )
512+ . map_err ( |_| crate :: ctl:: CtlError ( String :: from ( "pane id must be a number" ) ) ) ?;
513+ let keys = match keys. split_first ( ) {
514+ Some ( ( first, rest) ) if first == "--" => rest. join ( " " ) ,
515+ _ => keys. join ( " " ) ,
516+ } ;
517+ crate :: ctl:: CtlAction :: Send { pane_id, keys }
518+ }
519+ CtlCommand :: Focus { agent } => crate :: ctl:: CtlAction :: Focus {
520+ agent : agent. clone ( ) ,
521+ } ,
522+ CtlCommand :: Events => crate :: ctl:: CtlAction :: Events ,
523+ } ;
524+ crate :: ctl:: run ( & descriptor. socket_path , action)
525+ }
526+
527+ fn resolve_ctl_session (
528+ name : Option < & str > ,
529+ ) -> Result < crate :: session_store:: SessionDescriptor , Box < dyn Error > > {
530+ if let Some ( name) = name {
531+ return find_live ( name) ;
532+ }
533+ let live = crate :: session_store:: SessionStore :: for_current_user ( ) ?. list_live ( ) ?;
534+ let pairing = crate :: pairing:: load_or_empty ( ) ;
535+ let candidates = if pairing. can_rejoin ( ) {
536+ hosting_the_fleet ( & live, pairing. ticket . as_deref ( ) )
537+ } else {
538+ live
539+ } ;
540+ newest_live ( & candidates) . ok_or_else ( || {
541+ CliError ( "no live session here; start p2pmux first, then ctl talks to it" ) . into ( )
542+ } )
543+ }
544+
464545/// The live sessions on this machine, as `ticket`, `code`, `attach`, `kill` and `rename` name them.
465546///
466547/// Those five commands all take a session name and every one of them documented `p2pmux list` as
@@ -569,7 +650,8 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn Error>> {
569650 | Some ( Command :: List )
570651 | Some ( Command :: Setup { .. } )
571652 | Some ( Command :: Telemetry { .. } )
572- | Some ( Command :: Doctor ) => Ok ( ( ) ) ,
653+ | Some ( Command :: Doctor )
654+ | Some ( Command :: Ctl { .. } ) => Ok ( ( ) ) ,
573655 Some ( Command :: Local ) => crate :: tui:: run_local ( ) ,
574656 Some ( Command :: Config { command } ) => match command {
575657 ConfigCommand :: Init => {
0 commit comments