Skip to content

Commit 9766b3c

Browse files
authored
Merge pull request #142 from pelazas/feat/ctl-spawn-new
ctl: spawn --new and name guest-visible panes
2 parents 0962abd + b29037f commit 9766b3c

9 files changed

Lines changed: 211 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ numbers and which end is old. A node that does not know ctl yet is told to upgra
2727
left hanging.
2828

2929
`spawn` reuses a live inbox pane (`chat: {command}`) on that machine, waits until the pane exists,
30-
and will not nest `p2pmux`. `send` is raw PTY bytes and fails out loud when another member holds
31-
the input lease.
30+
and will not nest `p2pmux`. `spawn --new` always starts a pane. The reply includes
31+
`visible_to_guests` when someone unpaired is in the session. `send` is raw PTY bytes and fails out
32+
loud when another member holds the input lease. A ctl client that hangs up is dropped rather than
33+
held until the cap.
3234

3335
## v0.1.15 — 2026-09-01
3436

USAGE.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,23 +89,32 @@ Stdout is JSON. Failures are one sentence on stderr. The six verbs:
8989
```text
9090
p2pmux ctl [--session NAME] machines
9191
p2pmux ctl [--session NAME] agents
92-
p2pmux ctl [--session NAME] spawn --machine NAME -- <command...>
92+
p2pmux ctl [--session NAME] spawn [--new] --machine NAME -- <command...>
9393
p2pmux ctl [--session NAME] send <pane-id> [--] <keys>
9494
p2pmux ctl [--session NAME] focus <agent>
9595
p2pmux ctl [--session NAME] events
9696
```
9797

9898
`machines` is the pairing-owned list, including this machine. `agents` is the same roster Ctrl+O
9999
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.
100+
command is empty. Nested `p2pmux` is refused. The command waits until the pane exists.
101+
102+
Without `--new`, a live pane already titled `chat: {command}` on that host is reused; a pane that
103+
has exited or finished is not. `--new` always starts a pane, which is what two jobs on one
104+
machine need. The JSON includes `visible_to_guests` when someone unpaired is in the session: the
105+
pane still opens, and anyone holding the join code can see it. Omit `--session` so ctl talks to
106+
the fleet session; a runner that wanted a private fleet skips the spawn when that flag is true.
103107

104108
`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+
else holds the pane's input lease. It is typing, not a way to hand work from one agent to
110+
another. `focus` only moves to an agent that already has a pane — `p2pmux ctl spawn` is how one
111+
gets there. `events` prints `needs_you` / `done` / `error` as JSON lines: a snapshot first, then
112+
each change. The `message` field is present only when this node already has it, so a laptop
113+
watching a droplet sees the state and not the sentence.
114+
115+
One agent finishing so another can start: wait on `events` until that `pane_id` is `done`, then
116+
`spawn --new` with the next command. The payload belongs on the command line (`claude -p …`),
117+
not in `send`.
109118

110119
Ctl has its own pin, currently 1, independent of the peer wire pin. A client and a node that do
111120
not share it are told both numbers and which end to upgrade. A node too old to speak ctl at all

src/cli.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ enum CtlCommand {
234234
Spawn {
235235
#[arg(long)]
236236
machine: String,
237+
/// Do not reuse a live pane titled `chat: {command}` on that machine.
238+
#[arg(long)]
239+
new: bool,
237240
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
238241
command: Vec<String>,
239242
},
@@ -502,9 +505,14 @@ fn run_ctl(session: Option<&str>, action: &CtlCommand) -> Result<(), Box<dyn Err
502505
let action = match action {
503506
CtlCommand::Machines => crate::ctl::CtlAction::Machines,
504507
CtlCommand::Agents => crate::ctl::CtlAction::Agents,
505-
CtlCommand::Spawn { machine, command } => crate::ctl::CtlAction::Spawn {
508+
CtlCommand::Spawn {
509+
machine,
510+
command,
511+
new,
512+
} => crate::ctl::CtlAction::Spawn {
506513
machine: machine.clone(),
507514
command: command.clone(),
515+
new: *new,
508516
},
509517
CtlCommand::Send { pane, keys } => {
510518
let pane_id = pane

src/ctl.rs

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ pub enum CtlFromClient {
3838
machine: String,
3939
#[serde(default)]
4040
command: Vec<String>,
41+
/// Skip reuse of a live `chat: {command}` pane. Omitted by older
42+
/// clients, which keep the inbox behaviour.
43+
#[serde(default)]
44+
new: bool,
4145
},
4246
Send {
4347
pane_id: u64,
@@ -69,6 +73,8 @@ pub enum CtlToClient {
6973
Spawned {
7074
pane_id: u64,
7175
reused: bool,
76+
#[serde(default)]
77+
visible_to_guests: bool,
7278
},
7379
Sent {
7480
pane_id: u64,
@@ -122,6 +128,7 @@ pub enum CtlAction {
122128
Spawn {
123129
machine: String,
124130
command: Vec<String>,
131+
new: bool,
125132
},
126133
Send {
127134
pane_id: u64,
@@ -246,9 +253,14 @@ pub fn run(socket: &Path, action: CtlAction) -> Result<(), Box<dyn Error>> {
246253
let request = match &action {
247254
CtlAction::Machines => CtlFromClient::Machines,
248255
CtlAction::Agents => CtlFromClient::Agents,
249-
CtlAction::Spawn { machine, command } => CtlFromClient::Spawn {
256+
CtlAction::Spawn {
257+
machine,
258+
command,
259+
new,
260+
} => CtlFromClient::Spawn {
250261
machine: machine.clone(),
251262
command: command.clone(),
263+
new: *new,
252264
},
253265
CtlAction::Send { pane_id, keys } => CtlFromClient::Send {
254266
pane_id: *pane_id,
@@ -264,13 +276,14 @@ pub fn run(socket: &Path, action: CtlAction) -> Result<(), Box<dyn Error>> {
264276
if matches!(action, CtlAction::Events) {
265277
reader.get_mut().set_read_timeout(None)?;
266278
loop {
267-
match receive_json::<CtlToClient>(&mut reader)? {
268-
Some(event @ CtlToClient::Event { .. }) => {
279+
match receive_json::<CtlToClient>(&mut reader) {
280+
Ok(Some(event @ CtlToClient::Event { .. })) => {
269281
println!("{}", serde_json::to_string(&event)?);
270282
}
271-
Some(CtlToClient::Error { message }) => return Err(CtlError(message).into()),
272-
Some(_) => {}
273-
None => return Ok(()),
283+
Ok(Some(CtlToClient::Error { message })) => return Err(CtlError(message).into()),
284+
Ok(Some(_)) | Ok(None) => {}
285+
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
286+
Err(error) => return Err(error.into()),
274287
}
275288
}
276289
}
@@ -291,12 +304,18 @@ pub fn run(socket: &Path, action: CtlAction) -> Result<(), Box<dyn Error>> {
291304
serde_json::to_string(&serde_json::json!({ "agents": agents }))?
292305
);
293306
}
294-
Some(CtlToClient::Spawned { pane_id, reused }) => {
307+
Some(CtlToClient::Spawned {
308+
pane_id,
309+
reused,
310+
visible_to_guests,
311+
}) => {
295312
println!(
296313
"{}",
297-
serde_json::to_string(
298-
&serde_json::json!({ "pane_id": pane_id, "reused": reused })
299-
)?
314+
serde_json::to_string(&serde_json::json!({
315+
"pane_id": pane_id,
316+
"reused": reused,
317+
"visible_to_guests": visible_to_guests,
318+
}))?
300319
);
301320
}
302321
Some(CtlToClient::Sent { pane_id }) => {
@@ -354,7 +373,10 @@ pub fn receive_json<T: for<'de> Deserialize<'de>>(
354373
Err(error) => return Err(error),
355374
};
356375
if count == 0 {
357-
return Ok(None);
376+
return Err(io::Error::new(
377+
io::ErrorKind::UnexpectedEof,
378+
"ctl connection closed",
379+
));
358380
}
359381
if count > MAX_FRAME {
360382
return Err(io::Error::new(
@@ -417,6 +439,50 @@ mod tests {
417439
assert!(!is_nested_p2pmux(&[]));
418440
}
419441

442+
#[test]
443+
fn spawn_without_new_keeps_reusing() {
444+
let spawn: CtlFromClient = serde_json::from_value(serde_json::json!({
445+
"type": "spawn",
446+
"machine": "droplet",
447+
"command": ["claude"]
448+
}))
449+
.unwrap();
450+
assert_eq!(
451+
spawn,
452+
CtlFromClient::Spawn {
453+
machine: String::from("droplet"),
454+
command: vec![String::from("claude")],
455+
new: false,
456+
}
457+
);
458+
459+
let forced = serde_json::to_value(CtlFromClient::Spawn {
460+
machine: String::from("droplet"),
461+
command: vec![String::from("claude")],
462+
new: true,
463+
})
464+
.unwrap();
465+
assert_eq!(forced["new"], true);
466+
}
467+
468+
#[test]
469+
fn spawned_without_visible_to_guests_defaults_false() {
470+
let spawned: CtlToClient = serde_json::from_value(serde_json::json!({
471+
"type": "spawned",
472+
"pane_id": 3,
473+
"reused": true
474+
}))
475+
.unwrap();
476+
assert_eq!(
477+
spawned,
478+
CtlToClient::Spawned {
479+
pane_id: 3,
480+
reused: true,
481+
visible_to_guests: false,
482+
}
483+
);
484+
}
485+
420486
#[test]
421487
fn ctl_pin_does_not_move_the_wire_pin() {
422488
assert_eq!(CTL_PROTOCOL_PIN, 1);

src/node.rs

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ struct CtlClient {
12641264
events: bool,
12651265
seen_events: BTreeSet<(String, String)>,
12661266
spawn_wait: Option<u64>,
1267-
spawn_retry: Option<(String, Vec<String>)>,
1267+
spawn_retry: Option<(String, Vec<String>, bool)>,
12681268
}
12691269

12701270
fn accept_ctl_hello(mut reader: BufReader<UnixStream>, pin: u32, clients: &mut Vec<CtlClient>) {
@@ -1323,8 +1323,8 @@ fn poll_ctl_clients(clients: &mut Vec<CtlClient>, node: &mut SharedLayoutNode) -
13231323

13241324
fn poll_one_ctl(client: &mut CtlClient, node: &mut SharedLayoutNode) -> io::Result<bool> {
13251325
let mut did_work = false;
1326-
if let Some((machine, command)) = client.spawn_retry.clone() {
1327-
match dispatch_ctl_spawn(node, client, machine, command) {
1326+
if let Some((machine, command, new)) = client.spawn_retry.clone() {
1327+
match dispatch_ctl_spawn(node, client, machine, command, new) {
13281328
Ok(true) => {
13291329
client.spawn_retry = None;
13301330
did_work = true;
@@ -1342,13 +1342,9 @@ fn poll_one_ctl(client: &mut CtlClient, node: &mut SharedLayoutNode) -> io::Resu
13421342
{
13431343
client.spawn_wait = None;
13441344
match result {
1345-
Ok(pane_id) => ctl::write_json(
1346-
client.reader.get_mut(),
1347-
&CtlToClient::Spawned {
1348-
pane_id,
1349-
reused: false,
1350-
},
1351-
)?,
1345+
Ok(pane_id) => {
1346+
ctl::write_json(client.reader.get_mut(), &ctl_spawned(node, pane_id, false))?
1347+
}
13521348
Err(message) => {
13531349
ctl::write_json(client.reader.get_mut(), &CtlToClient::Error { message })?
13541350
}
@@ -1386,8 +1382,12 @@ fn poll_one_ctl(client: &mut CtlClient, node: &mut SharedLayoutNode) -> io::Resu
13861382
)?;
13871383
did_work = true;
13881384
}
1389-
Ok(Some(CtlFromClient::Spawn { machine, command })) => {
1390-
match dispatch_ctl_spawn(node, client, machine, command) {
1385+
Ok(Some(CtlFromClient::Spawn {
1386+
machine,
1387+
command,
1388+
new,
1389+
})) => {
1390+
match dispatch_ctl_spawn(node, client, machine, command, new) {
13911391
Ok(_) => {}
13921392
Err(message) => {
13931393
ctl::write_json(client.reader.get_mut(), &CtlToClient::Error { message })?
@@ -1432,7 +1432,9 @@ fn poll_one_ctl(client: &mut CtlClient, node: &mut SharedLayoutNode) -> io::Resu
14321432
Err(error)
14331433
if matches!(
14341434
error.kind(),
1435-
io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe
1435+
io::ErrorKind::ConnectionReset
1436+
| io::ErrorKind::BrokenPipe
1437+
| io::ErrorKind::UnexpectedEof
14361438
) =>
14371439
{
14381440
return Err(error);
@@ -1447,35 +1449,26 @@ fn dispatch_ctl_spawn(
14471449
client: &mut CtlClient,
14481450
machine: String,
14491451
command: Vec<String>,
1452+
new: bool,
14501453
) -> Result<bool, String> {
1451-
match node.runtime.ctl_try_spawn(&machine, command.clone()) {
1454+
match node.runtime.ctl_try_spawn(&machine, command.clone(), new) {
14521455
Ok(crate::ctl::CtlSpawn::Reused(pane_id)) => {
1453-
ctl::write_json(
1454-
client.reader.get_mut(),
1455-
&CtlToClient::Spawned {
1456-
pane_id,
1457-
reused: true,
1458-
},
1459-
)
1460-
.map_err(|error| error.to_string())?;
1456+
ctl::write_json(client.reader.get_mut(), &ctl_spawned(node, pane_id, true))
1457+
.map_err(|error| error.to_string())?;
14611458
Ok(true)
14621459
}
14631460
Ok(crate::ctl::CtlSpawn::Busy) => {
1464-
client.spawn_retry = Some((machine, command));
1461+
client.spawn_retry = Some((machine, command, new));
14651462
Ok(false)
14661463
}
14671464
Ok(crate::ctl::CtlSpawn::Started(request_id)) => {
14681465
let _ = node.drain();
14691466
if let Some(result) = node.runtime.ctl_take_result(request_id) {
14701467
match result {
1471-
Ok(pane_id) => ctl::write_json(
1472-
client.reader.get_mut(),
1473-
&CtlToClient::Spawned {
1474-
pane_id,
1475-
reused: false,
1476-
},
1477-
)
1478-
.map_err(|error| error.to_string())?,
1468+
Ok(pane_id) => {
1469+
ctl::write_json(client.reader.get_mut(), &ctl_spawned(node, pane_id, false))
1470+
.map_err(|error| error.to_string())?
1471+
}
14791472
Err(message) => {
14801473
return Err(message);
14811474
}
@@ -1489,6 +1482,14 @@ fn dispatch_ctl_spawn(
14891482
}
14901483
}
14911484

1485+
fn ctl_spawned(node: &mut SharedLayoutNode, pane_id: u64, reused: bool) -> CtlToClient {
1486+
CtlToClient::Spawned {
1487+
pane_id,
1488+
reused,
1489+
visible_to_guests: node.runtime.ctl_visible_to_guests(),
1490+
}
1491+
}
1492+
14921493
fn publish_ctl_events(client: &mut CtlClient, node: &mut SharedLayoutNode) -> io::Result<bool> {
14931494
let mut wrote = false;
14941495
let mut seen = BTreeSet::new();

0 commit comments

Comments
 (0)