Skip to content

Commit fa0f0d0

Browse files
authored
Merge pull request #138 from pelazas/feat/ctl
ctl: drive a session from the CLI
2 parents 0234809 + 22eb045 commit fa0f0d0

11 files changed

Lines changed: 1674 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ enrolment token — and a peer on the wrong side of it is refused rather than ha
1111
v0.1.10 that refusal says so in as many words, naming both protocol numbers and which machine is the
1212
old one; older peers still report it as a host they could not reach.
1313

14+
## Unreleased
15+
16+
**`p2pmux ctl` drives a live session from the CLI, without taking the TUI seat.** Six verbs
17+
(`machines`, `agents`, `spawn`, `send`, `focus`, `events`) speak JSON on stdout to the session
18+
node. Ctl has its own pin, currently 1. The peer wire pin is still 12: a ctl client and a session
19+
peer are different sockets, and moving one does not move the other. A pin mismatch names both
20+
numbers and which end is old. A node that does not know ctl yet is told to upgrade it rather than
21+
left hanging.
22+
23+
`spawn` reuses a live inbox pane (`chat: {command}`) on that machine, waits until the pane exists,
24+
and will not nest `p2pmux`. `send` is raw PTY bytes and fails out loud when another member holds
25+
the input lease.
26+
1427
## v0.1.15 — 2026-09-01
1528

1629
Ten fixes and a shorter first-run telemetry ask, and no protocol change that costs anything: one field is added to the agent roster,

USAGE.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,40 @@ Terminal 2: cargo run -- join <the code from Ctrl+S>
7777
Its PTY grid is fixed from the terminal size at startup: larger windows leave extra cells blank
7878
and smaller windows crop the upper-left fixed viewport.
7979

80+
## Driving a session from a script
81+
82+
`p2pmux ctl` talks to a live session without taking the TUI seat, so a fleet-agent node with
83+
nobody attached still answers. It does not start a session: if none is running, start `p2pmux`
84+
first. `--session` names one as `p2pmux list` prints it; omit it for the fleet session when this
85+
machine is paired, otherwise the newest live one.
86+
87+
Stdout is JSON. Failures are one sentence on stderr. The six verbs:
88+
89+
```text
90+
p2pmux ctl [--session NAME] machines
91+
p2pmux ctl [--session NAME] agents
92+
p2pmux ctl [--session NAME] spawn --machine NAME -- <command...>
93+
p2pmux ctl [--session NAME] send <pane-id> [--] <keys>
94+
p2pmux ctl [--session NAME] focus <agent>
95+
p2pmux ctl [--session NAME] events
96+
```
97+
98+
`machines` is the pairing-owned list, including this machine. `agents` is the same roster Ctrl+O
99+
shows. `spawn` starts an allowlisted command on a machine you own, or a login shell when the
100+
command is empty. A live pane already titled `chat: {command}` on that host is reused; a pane
101+
that has exited or finished is not. Nested `p2pmux` is refused. The command waits until the pane
102+
exists.
103+
104+
`send` writes the keys as PTY bytes, with no extra newline and no key names. It fails if someone
105+
else holds the pane's input lease. `focus` only moves to an agent that already has a pane —
106+
`p2pmux ctl spawn` is how one gets there. `events` prints `needs_you` / `done` / `error` as JSON
107+
lines: a snapshot first, then each change. The `message` field is present only when this node
108+
already has it.
109+
110+
Ctl has its own pin, currently 1, independent of the peer wire pin. A client and a node that do
111+
not share it are told both numbers and which end to upgrade. A node too old to speak ctl at all
112+
says so after a short wait rather than hanging.
113+
80114
## Inviting someone
81115

82116
`Ctrl+S` shows the line your guest runs. Enter copies it; send it as-is:

src/cli.rs

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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)]
219253
enum 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

Comments
 (0)