cctop 0.3.0

An htop-like terminal monitor for AI coding agent sessions (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Pi, Windsurf)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
//! Type a line into the terminal driving a live agent session.
//!
//! An interactive agent reads its keyboard from the pty its terminal owns, and
//! only whoever holds that pty's master side can push bytes in. Writing to
//! `/proc/<pid>/fd/0` or `/dev/pts/N` reaches the *output* side and merely paints
//! the screen, and the master can't be reopened through `/proc` — that symlink
//! points at `/dev/ptmx`, so opening it mints a fresh pty instead. So each
//! backend here is a different answer to "who holds the master":
//!
//! 1. [`shim`](crate::shim) — cctop does, because the agent was started by
//!    `cctop run`. Works anywhere, needs no privileges.
//! 2. tmux — the server does, and `send-keys` asks it politely.
//! 3. `TIOCSTI` — nobody has to: the kernel pushes a byte into the slave's own
//!    input queue. Needs root for a foreign tty and `dev.tty.legacy_tiocsti=1`,
//!    both off by default, and it's the one path that reaches sessions started
//!    before cctop was involved.
//!
//! ponytail: no screen or zellij backend. `screen -X stuff` reaches a session's
//! *current* window and zellij's `write-chars` its *focused* pane, neither
//! targetable from a PID — add when someone asks.

use std::process::Command;
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};

/// What a real Enter key sends on a pty in raw mode. A `\n` is read as Enter by
/// some agents and ignored by others; `\r` is what the terminal would have sent.
///
/// Gated with its users: both backends that write raw bytes to a pty are unix-only,
/// and tmux submits with a named `Enter` key instead, so on Windows this is dead.
#[cfg(unix)]
const SUBMIT: char = '\r';

/// Gap between the text and the Enter that submits it.
///
/// An agent's TUI reads its keyboard in chunks, and a `\r` arriving in the same
/// chunk as the text is not a keypress — it is the newline in the middle of a
/// paste, which Claude Code keeps in the prompt instead of sending. Whether the
/// two land in one read is a race, so `s` submitted sometimes and left the line
/// sitting there other times. tmux never sees this because its Enter is a
/// second `send-keys` round trip; the paths that write bytes straight to the pty
/// have to leave the gap themselves.
///
// ponytail: a fixed delay, tuned against Claude Code. If some agent still eats
// the Enter, this is the number to raise.
#[cfg(unix)]
const SETTLE: std::time::Duration = std::time::Duration::from_millis(60);

/// Type `text` into the terminal running the agent at `pid`, then submit it.
///
/// Backends are tried strongest first; each reports `None` when it doesn't apply
/// to this session, so a session under `cctop run` never falls through to the
/// root-only path and the error the user sees names every option they have.
pub fn send_line(pid: u32, text: &str) -> Result<(), String> {
    for backend in [shim_send, tmux_send, tiocsti_send] {
        if let Some(result) = backend(pid, text) {
            return result;
        }
    }
    Err(format!(
        "no way to type into session {pid}: start the agent with `cctop run <agent>` \
         or inside tmux, or run cctop as root with dev.tty.legacy_tiocsti=1"
    ))
}

/// Hand the line to the `cctop run` shim that owns this agent's pty.
#[cfg(unix)]
fn shim_send(pid: u32, text: &str) -> Option<Result<(), String>> {
    let path = crate::shim::socket_path(pid)?;
    // A stale socket file from a crashed shim refuses connections, which is
    // indistinguishable from "no shim" and correctly falls through.
    let mut stream = std::os::unix::net::UnixStream::connect(path).ok()?;
    Some(write_then_submit(&mut stream, text).map_err(|e| format!("cctop run socket: {e}")))
}

/// Type `text`, let the agent take it in, then press Enter — see [`SETTLE`] for
/// why those cannot be one write.
#[cfg(unix)]
fn write_then_submit(out: &mut impl std::io::Write, text: &str) -> std::io::Result<()> {
    out.write_all(text.as_bytes())?;
    out.flush()?;
    std::thread::sleep(SETTLE);
    out.write_all(&[SUBMIT as u8])?;
    out.flush()
}

#[cfg(not(unix))]
fn shim_send(_pid: u32, _text: &str) -> Option<Result<(), String>> {
    None
}

/// Ask tmux to type into the pane holding this agent.
fn tmux_send(pid: u32, text: &str) -> Option<Result<(), String>> {
    let pane = pane_for(pid)?;
    Some(send(&pane, text))
}

/// Push the line into the tty's input queue with `TIOCSTI`.
///
/// The last resort, and the only one that reaches a session already running in a
/// plain terminal. Both of its preconditions are off by default, so an applicable
/// session with an unmet precondition returns the reason rather than falling
/// through to a less specific error.
#[cfg(target_os = "linux")]
fn tiocsti_send(pid: u32, text: &str) -> Option<Result<(), String>> {
    use std::os::fd::AsRawFd;

    let tty = std::fs::read_link(format!("/proc/{pid}/fd/0")).ok()?;
    if !tty.starts_with("/dev/pts/") {
        return None;
    }
    // No privilege pre-check: the kernel's two refusals say different things, and
    // it allows the unprivileged case where cctop shares the agent's controlling
    // terminal. Letting the ioctl decide is both shorter and more capable.
    let file = match std::fs::OpenOptions::new().write(true).open(&tty) {
        Ok(f) => f,
        Err(e) => return Some(Err(format!("{}: {e}", tty.display()))),
    };
    let push = |byte: u8| {
        // SAFETY: writing one byte through a live tty fd; TIOCSTI takes a
        // pointer to that single byte.
        if unsafe { libc::ioctl(file.as_raw_fd(), libc::TIOCSTI as _, &byte) } != -1 {
            return Ok(());
        }
        let err = std::io::Error::last_os_error();
        // The kernel distinguishes the two preconditions: EIO is the disabled
        // sysctl, EPERM is a tty that isn't ours and so needs CAP_SYS_ADMIN.
        Err(match err.raw_os_error() {
            // Checked before the tty-ownership gate, so CAP_SYS_ADMIN clears
            // both and root never reaches this branch.
            Some(libc::EIO) => "the kernel has TIOCSTI disabled: run cctop as root, or \
                 sysctl -w dev.tty.legacy_tiocsti=1"
                .into(),
            Some(libc::EPERM) => format!(
                "typing into {} needs cctop as root (or start the agent with `cctop run`)",
                tty.display()
            ),
            _ => format!("TIOCSTI: {err}"),
        })
    };
    for byte in text.bytes() {
        if let Err(e) = push(byte) {
            return Some(Err(e));
        }
    }
    // Same reason as the shim path: the line reaches the input queue as one
    // burst, so the Enter needs its own moment or it reads as a paste.
    std::thread::sleep(SETTLE);
    Some(push(SUBMIT as u8))
}

#[cfg(not(target_os = "linux"))]
fn tiocsti_send(_pid: u32, _text: &str) -> Option<Result<(), String>> {
    None
}

/// How far up the process tree to look for a pane before giving up. Deep enough
/// for a shell under a wrapper under a pane, short enough to never spin.
const MAX_DEPTH: usize = 32;

/// The tmux pane hosting `pid`, if it is in one.
///
/// tmux reports each pane's own child — usually the shell the agent was launched
/// from, or the agent itself when it *is* the pane command — so the two meet by
/// walking up from the agent.
fn pane_for(pid: u32) -> Option<String> {
    pane_in(&list_panes()?, pid)
}

/// The walk itself, over a pane list the caller supplies.
///
/// Split out because the hijack below cannot be reproduced against the real
/// server without typing into whichever pane the test runner happens to sit in
/// — which is the bug, not a way to test it.
fn pane_in(panes: &[(u32, String)], pid: u32) -> Option<String> {
    let mut sys = System::new();
    sys.refresh_processes_specifics(ProcessesToUpdate::All, false, ProcessRefreshKind::nothing());

    // Climbing past cctop itself leaves the agent's terminal and enters cctop's
    // own, so the pane it then finds belongs to the user's shell rather than to
    // the session being typed into. That is not a near miss: it sends the line
    // to whatever the user is doing right now. It bit for real — a pty child
    // spawned by the test suite has cctop as its parent, so `send_line` walked
    // out of the pty, up through cargo, and typed "continue" into the terminal
    // the developer was working in.
    //
    // A session cctop launched itself is reached by `shim_send` through its
    // socket, and one launched into tmux belongs to the tmux server rather than
    // to cctop, so neither legitimate case needs this walk to climb that far.
    let own = std::process::id();

    let mut current = pid;
    for _ in 0..MAX_DEPTH {
        if current == own {
            return None;
        }
        if let Some((_, pane)) = panes.iter().find(|(pane_pid, _)| *pane_pid == current) {
            return Some(pane.clone());
        }
        let parent = sys.process(Pid::from_u32(current))?.parent()?.as_u32();
        if parent == 0 || parent == current {
            return None;
        }
        current = parent;
    }
    None
}

/// Type `text` into `pane` and submit it.
fn send(pane: &str, text: &str) -> Result<(), String> {
    // `-l --` sends the text literally, so a message containing "Enter" or "C-c"
    // is typed rather than interpreted, and a leading dash isn't read as a flag.
    // The newline that submits it has to be a separate, non-literal key.
    tmux(&["send-keys", "-t", pane, "-l", "--", text])?;
    tmux(&["send-keys", "-t", pane, "Enter"])
}

/// Every pane on the server as `(pane_pid, pane_id)`.
///
/// `None` when tmux isn't installed or no server is running — both mean "this
/// session isn't in a pane", which is the caller's only question.
fn list_panes() -> Option<Vec<(u32, String)>> {
    let out = Command::new("tmux")
        .args(["list-panes", "-a", "-F", "#{pane_pid} #{pane_id}"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    Some(
        String::from_utf8_lossy(&out.stdout)
            .lines()
            .filter_map(|line| {
                let (pid, id) = line.split_once(' ')?;
                Some((pid.parse().ok()?, id.to_string()))
            })
            .collect(),
    )
}

fn tmux(args: &[&str]) -> Result<(), String> {
    let out = Command::new("tmux")
        .args(args)
        .output()
        .map_err(|e| format!("tmux: {e}"))?;
    if out.status.success() {
        return Ok(());
    }
    let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
    Err(if err.is_empty() {
        "tmux rejected the keys".into()
    } else {
        err
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The Enter must reach the agent as its own write. Appended to the text it
    /// can land in the same read, where a TUI takes it for the newline inside a
    /// paste and the line is typed but never sent — which is what `s` did.
    #[cfg(unix)]
    #[test]
    fn the_submit_key_is_written_apart_from_the_line() {
        use std::io::Write;

        /// Records what each `write_all` was given, which is exactly the
        /// distinction the agent's read loop can see.
        struct Writes(Vec<Vec<u8>>);
        impl Write for Writes {
            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
                self.0.push(buf.to_vec());
                Ok(buf.len())
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }

        let mut out = Writes(Vec::new());
        let start = std::time::Instant::now();
        write_then_submit(&mut out, "continue").unwrap();

        assert_eq!(out.0, vec![b"continue".to_vec(), vec![b'\r']]);
        assert!(
            start.elapsed() >= SETTLE,
            "the two writes went out back to back, which is the race this avoids"
        );
    }

    /// A child of cctop's own process must never resolve to a pane, because the
    /// only pane above it is the one cctop is running in — the user's terminal.
    ///
    /// The regression: `send_line` on a pty child spawned by the suite walked
    /// out of the pty, up through cargo and the shell, and typed "continue" into
    /// whatever the developer had on screen. Driven through a synthetic pane
    /// list, since reproducing it against the real server means doing it again.
    #[cfg(unix)]
    #[test]
    fn the_walk_stops_before_it_reaches_cctops_own_terminal() {
        let mut child = Command::new("sleep")
            .arg("30")
            .spawn()
            .expect("spawn a child to walk up from");
        let kid = child.id();

        // Claim cctop's own parent is a pane, exactly as the real server would
        // report the shell that cargo was launched from.
        let ancestor = {
            let mut sys = System::new();
            sys.refresh_processes_specifics(
                ProcessesToUpdate::All,
                false,
                ProcessRefreshKind::nothing(),
            );
            sys.process(Pid::from_u32(std::process::id()))
                .and_then(|p| p.parent())
                .map(|p| p.as_u32())
        };
        let ancestor = ancestor.expect("cctop's own parent is what the walk must not reach");

        // Both probes run while the child is alive: once it is reaped the walk
        // stops at the missing process and returns `None` for a reason that has
        // nothing to do with the guard, which is how the first cut of this test
        // passed against the unfixed code.
        let hijacked = pane_in(&[(ancestor, "%99".to_string())], kid);
        // Positive control: a pane that really is the target still resolves, so
        // the guard is not simply refusing everything.
        let found = pane_in(&[(kid, "%7".to_string())], kid);

        let _ = child.kill();
        let _ = child.wait();

        assert_eq!(
            found.as_deref(),
            Some("%7"),
            "a real pane must still resolve"
        );
        assert_eq!(
            hijacked, None,
            "the walk climbed through cctop into the user's own pane"
        );
    }

    /// The whole feature is the round trip: find the pane holding a process we
    /// only know by PID, then have what we send arrive as that process's input.
    /// Nothing smaller than a real tmux server tests either half.
    #[test]
    fn types_into_the_pane_holding_a_pid() {
        if Command::new("tmux").arg("-V").output().is_err() {
            eprintln!("skipping: tmux not installed");
            return;
        }
        // Every test that drives the real server takes its turn — see
        // [`tmux::test_lock`](crate::tmux::test_lock).
        let _turn = crate::tmux::test_lock();
        let out = std::env::temp_dir().join("cctop-mux-test.txt");
        let _ = std::fs::remove_file(&out);
        // `tee` takes the path as an argument, so the reader is findable by
        // cmdline; the trailing `:` stops the shell from exec'ing it, keeping a
        // shell between the pane and the reader so the ancestor walk has to
        // climb at least one level.
        let session = "cctop-mux-test";
        // The name is fixed, so a run killed before its teardown leaves the
        // session behind and `new-session` then fails as a duplicate — for every
        // run after it, until someone thinks to look in tmux.
        let _ = Command::new("tmux")
            .args(["kill-session", "-t", &format!("={session}")])
            .status();
        let script = format!("tee {} >/dev/null; :", out.display());
        assert!(
            Command::new("tmux")
                .args(["new-session", "-d", "-s", session, "sh", "-c", &script])
                .status()
                .unwrap()
                .success()
        );

        let reader = wait_for(|| {
            let sys = {
                let mut s = System::new();
                s.refresh_processes_specifics(
                    ProcessesToUpdate::All,
                    true,
                    ProcessRefreshKind::nothing().with_cmd(sysinfo::UpdateKind::Always),
                );
                s
            };
            sys.processes()
                .values()
                .find(|p| {
                    p.name().to_string_lossy().starts_with("tee")
                        && p.cmd()
                            .iter()
                            .any(|a| a.to_string_lossy().contains("cctop-mux-test.txt"))
                })
                .map(|p| p.pid().as_u32())
        });

        let pane = reader.and_then(pane_for);
        // Wait for the input to arrive *before* tearing the session down. Killing
        // it first destroys the child mid-read, discarding the very thing under
        // test — which is why this passed locally and failed on a loaded runner.
        let text = pane.as_ref().and_then(|pane| {
            send(pane, "continue").unwrap();
            wait_for(|| std::fs::read_to_string(&out).ok().filter(|t| !t.is_empty()))
        });
        let _ = Command::new("tmux")
            .args(["kill-session", "-t", session])
            .status();
        let _ = std::fs::remove_file(&out);

        assert!(pane.is_some(), "no pane found for the reader process");
        assert_eq!(
            text.expect("nothing reached the child as input").trim(),
            "continue"
        );
    }

    /// tmux starts panes and flushes writes asynchronously; poll rather than
    /// guess a sleep long enough for a loaded CI box.
    fn wait_for<T>(mut f: impl FnMut() -> Option<T>) -> Option<T> {
        for _ in 0..50 {
            if let Some(v) = f() {
                return Some(v);
            }
            std::thread::sleep(std::time::Duration::from_millis(100));
        }
        None
    }
}