agtop 2.3.7

Terminal UI for monitoring AI coding agents (Claude Code, Codex, Aider, Cursor, Gemini, Goose, ...) — like top, but for agents.
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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
// Per-process writable-file enumeration, native on each OS.
//
// On Linux the original implementation in `proc_::read_writing_files`
// walks `/proc/<pid>/fdinfo` + `/proc/<pid>/fd`.  This module
// dispatches that on Linux and provides equivalent native
// implementations for macOS (libproc / proc_pidinfo) and Windows
// (NtQuerySystemInformation + DuplicateHandle + GetFinalPathNameByHandle).
//
// All implementations:
//   - return an empty `Vec` on any access denial / unknown PID
//     (never panic, never bubble errors)
//   - cap the result at `limit` entries so a process with thousands
//     of open files doesn't dominate one collector tick
//   - exclude pipes / sockets / device nodes / anonymous memory
//     mappings — only real on-disk files

use std::path::PathBuf;

/// Cross-platform entry point.  Returns `Vec<PathBuf>` of files the
/// target PID has open with write access (O_WRONLY / O_RDWR on POSIX,
/// FILE_GENERIC_WRITE on Windows).
pub fn read(pid: u32, limit: usize) -> Vec<PathBuf> {
    impl_::read(pid, limit)
}

// ── Linux ──────────────────────────────────────────────────────────────────
#[cfg(target_os = "linux")]
mod impl_ {
    use super::*;
    pub fn read(pid: u32, limit: usize) -> Vec<PathBuf> {
        crate::proc_::read_writing_files(pid, limit)
    }
}

// ── macOS ──────────────────────────────────────────────────────────────────
#[cfg(target_os = "macos")]
mod impl_ {
    use super::*;
    use std::ffi::c_void;
    use std::os::raw::{c_char, c_int, c_uint};

    // Apple's libproc.h flavor constants (stable since 10.5).
    const PROC_PIDLISTFDS:           c_int = 1;
    const PROC_PIDFDVNODEPATHINFO:   c_int = 2;
    const PROX_FDTYPE_VNODE:         u32   = 1;

    // libSystem.dylib symbols.  Available on every macOS without an
    // explicit `link` directive — the dynamic linker resolves them.
    extern "C" {
        fn proc_pidinfo(
            pid: c_int, flavor: c_int, arg: u64,
            buffer: *mut c_void, buffersize: c_int,
        ) -> c_int;
        fn proc_pidfdinfo(
            pid: c_int, fd: c_int, flavor: c_int,
            buffer: *mut c_void, buffersize: c_int,
        ) -> c_int;
    }

    #[repr(C)]
    #[derive(Default, Clone, Copy)]
    struct ProcFdInfo {
        proc_fd: c_int,
        proc_fdtype: c_uint,
    }

    /// Subset of `proc_fileinfo` from sys/proc_info.h.  We only read
    /// `fi_openflags`; the surrounding fields are present so the
    /// struct size matches what proc_pidfdinfo writes.
    #[repr(C)]
    #[derive(Default, Clone, Copy)]
    struct ProcFileInfo {
        fi_openflags: u32,
        fi_status:    u32,
        fi_offset:    i64,
        fi_type:      i32,
        fi_guardflags: u32,
    }

    #[repr(C)]
    #[derive(Clone, Copy)]
    struct VnodeInfo {
        // 152-byte stat block; we don't read any of it.
        _opaque: [u8; 152],
    }
    impl Default for VnodeInfo {
        fn default() -> Self { Self { _opaque: [0u8; 152] } }
    }

    #[repr(C)]
    #[derive(Clone, Copy)]
    struct VnodeInfoPath {
        vip_vi:   VnodeInfo,
        vip_path: [c_char; 1024],   // MAXPATHLEN, NUL-terminated
    }
    impl Default for VnodeInfoPath {
        fn default() -> Self { Self { vip_vi: VnodeInfo::default(), vip_path: [0; 1024] } }
    }

    #[repr(C)]
    #[derive(Default, Clone, Copy)]
    struct VnodeFdInfoWithPath {
        pfi:  ProcFileInfo,
        pvip: VnodeInfoPath,
    }

    pub fn read(pid: u32, limit: usize) -> Vec<PathBuf> {
        let pid = pid as c_int;

        // Step 1: probe how big the FD list is.
        let probe = unsafe {
            proc_pidinfo(pid, PROC_PIDLISTFDS, 0, std::ptr::null_mut(), 0)
        };
        if probe <= 0 { return Vec::new(); }
        // Cap at 4096 entries to avoid a runaway alloc on a process
        // with millions of fds.
        let needed = (probe as usize).min(4096 * std::mem::size_of::<ProcFdInfo>());
        let entry_count = needed / std::mem::size_of::<ProcFdInfo>();
        let mut buf: Vec<ProcFdInfo> = vec![ProcFdInfo::default(); entry_count];

        // Step 2: fetch the actual FD list.
        let written = unsafe {
            proc_pidinfo(
                pid, PROC_PIDLISTFDS, 0,
                buf.as_mut_ptr() as *mut c_void,
                (buf.len() * std::mem::size_of::<ProcFdInfo>()) as c_int,
            )
        };
        if written <= 0 { return Vec::new(); }
        let got = (written as usize) / std::mem::size_of::<ProcFdInfo>();

        let mut out: Vec<PathBuf> = Vec::with_capacity(limit.min(got));
        for fd in buf.iter().take(got) {
            if out.len() >= limit { break; }
            if fd.proc_fdtype != PROX_FDTYPE_VNODE { continue; }

            // Step 3: per-FD path + flags.
            let mut info = VnodeFdInfoWithPath::default();
            let n = unsafe {
                proc_pidfdinfo(
                    pid, fd.proc_fd, PROC_PIDFDVNODEPATHINFO,
                    &mut info as *mut _ as *mut c_void,
                    std::mem::size_of::<VnodeFdInfoWithPath>() as c_int,
                )
            };
            if n <= 0 { continue; }
            // O_WRONLY = 1, O_RDWR = 2 (POSIX).
            let flags = info.pfi.fi_openflags as i32;
            if flags & (libc::O_WRONLY | libc::O_RDWR) == 0 { continue; }

            // vip_path is NUL-terminated c_char (i8 on Apple).
            let path = &info.pvip.vip_path;
            let nul = path.iter().position(|&b| b == 0).unwrap_or(path.len());
            let bytes: Vec<u8> = path[..nul].iter().map(|&c| c as u8).collect();
            let s = match std::str::from_utf8(&bytes) {
                Ok(s) => s,
                Err(_) => continue,
            };
            if s.is_empty() || s.starts_with("/dev/") { continue; }
            out.push(PathBuf::from(s));
        }
        out
    }
}

// ── Windows ────────────────────────────────────────────────────────────────
#[cfg(windows)]
mod impl_ {
    use super::*;
    use std::ffi::OsString;
    use std::os::windows::ffi::OsStringExt;

    use windows_sys::Win32::Foundation::{
        CloseHandle, DuplicateHandle, DUPLICATE_SAME_ACCESS, HANDLE, INVALID_HANDLE_VALUE,
        STATUS_INFO_LENGTH_MISMATCH, NTSTATUS,
    };
    use windows_sys::Win32::Storage::FileSystem::{
        GetFinalPathNameByHandleW, FILE_GENERIC_WRITE, FILE_NAME_NORMALIZED,
    };
    use windows_sys::Win32::System::Threading::{
        GetCurrentProcess, OpenProcess, PROCESS_DUP_HANDLE,
    };
    use windows_sys::Wdk::System::SystemInformation::{
        NtQuerySystemInformation,
    };

    // Manual SystemExtendedHandleInformation = 0x40 (windows-sys
    // doesn't expose the constant for the extended variant).
    const SYSTEM_EXTENDED_HANDLE_INFORMATION: i32 = 0x40;
    // Object type index for File on Windows is variable across Windows
    // versions; rather than enumerate the type table we filter by
    // GrantedAccess including FILE_GENERIC_WRITE bits.

    #[repr(C)]
    struct SystemHandleInformationEx {
        number_of_handles: usize,
        reserved: usize,
        handles: [SystemHandleTableEntryInfoEx; 1],
    }

    #[repr(C)]
    #[derive(Clone, Copy)]
    struct SystemHandleTableEntryInfoEx {
        object: *mut std::ffi::c_void,
        unique_process_id: usize,
        handle_value: usize,
        granted_access: u32,
        creator_back_trace_index: u16,
        object_type_index: u16,
        handle_attributes: u32,
        reserved: u32,
    }

    pub fn read(pid: u32, limit: usize) -> Vec<PathBuf> {
        let target_pid = pid as usize;
        // Step 1: query the global handle table.  Windows refuses if
        // we don't pre-size the buffer, so loop until the call fits.
        let mut buf: Vec<u8> = vec![0u8; 256 * 1024];
        let table: *const SystemHandleInformationEx = loop {
            let mut needed: u32 = 0;
            let status: NTSTATUS = unsafe {
                NtQuerySystemInformation(
                    SYSTEM_EXTENDED_HANDLE_INFORMATION,
                    buf.as_mut_ptr() as *mut _,
                    buf.len() as u32,
                    &mut needed,
                )
            };
            if status == 0 { break buf.as_ptr() as *const _; }
            if status == STATUS_INFO_LENGTH_MISMATCH {
                // Grow generously — the handle table on a busy box can
                // be many MiB.  Cap at 64 MiB so a runaway query can't
                // OOM agtop.
                let grow = ((needed as usize).max(buf.len() * 2)).min(64 * 1024 * 1024);
                if grow == buf.len() { return Vec::new(); }
                buf.resize(grow, 0);
                continue;
            }
            return Vec::new();
        };

        // Step 2: enumerate, filtering to (a) handles owned by our PID
        // and (b) handles with FILE_GENERIC_WRITE in their granted-access
        // mask.  Open a single dup-source handle on the target process.
        let proc_handle: HANDLE = unsafe {
            OpenProcess(PROCESS_DUP_HANDLE, 0, pid)
        };
        if proc_handle.is_null() || proc_handle == INVALID_HANDLE_VALUE {
            return Vec::new();
        }

        let header = unsafe { &*table };
        let count = header.number_of_handles;
        let entries: *const SystemHandleTableEntryInfoEx = unsafe {
            (table as *const u8)
                .add(std::mem::size_of::<usize>() * 2) // skip number_of_handles + reserved
                as *const SystemHandleTableEntryInfoEx
        };

        let mut out: Vec<PathBuf> = Vec::with_capacity(limit);
        let me = unsafe { GetCurrentProcess() };

        for i in 0..count {
            if out.len() >= limit { break; }
            let entry = unsafe { *entries.add(i) };
            if entry.unique_process_id != target_pid { continue; }
            // FILE_GENERIC_WRITE = STANDARD_RIGHTS_WRITE | FILE_WRITE_DATA |
            // FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | SYNCHRONIZE = 0x120116.
            // Match any handle whose grant overlaps the write-data bit.
            if entry.granted_access & FILE_GENERIC_WRITE == 0 { continue; }

            // Step 3: duplicate into our process so we can resolve a path.
            let mut dup: HANDLE = std::ptr::null_mut();
            let ok = unsafe {
                DuplicateHandle(
                    proc_handle,
                    entry.handle_value as HANDLE,
                    me,
                    &mut dup,
                    0,
                    0,
                    DUPLICATE_SAME_ACCESS,
                )
            };
            if ok == 0 || dup.is_null() { continue; }

            // Step 4: ask Windows for the final path.
            let mut wbuf = [0u16; 32_768];
            let n = unsafe {
                GetFinalPathNameByHandleW(dup, wbuf.as_mut_ptr(), wbuf.len() as u32, FILE_NAME_NORMALIZED)
            };
            unsafe { CloseHandle(dup); }
            if n == 0 || (n as usize) > wbuf.len() { continue; }
            let s = OsString::from_wide(&wbuf[..n as usize]);
            let s = s.to_string_lossy();
            // Strip the `\\?\` long-path prefix Windows returns.
            let trimmed = s.strip_prefix(r"\\?\").unwrap_or(&s);
            // Skip device paths.
            if trimmed.starts_with(r"\Device\") { continue; }
            out.push(PathBuf::from(trimmed));
        }

        unsafe { CloseHandle(proc_handle); }
        out
    }
}

// ── FreeBSD / DragonFly: libprocstat ───────────────────────────────────────
//
// FreeBSD ships libprocstat (since 9.x) which gives us proper per-fd
// path resolution — the same data `fstat -p <pid>` reports.  DragonFly
// has a compatible procstat fork.
//
// OpenBSD / NetBSD don't track per-fd paths in the kernel (their
// kvm_getfiles returns inode + dev only, no path), so FD enumeration
// there can return numbers but not paths — almost useless for agtop's
// "writing files" surface.  Falls through to the empty-Vec stub.
#[cfg(any(target_os = "freebsd", target_os = "dragonfly"))]
mod impl_ {
    use super::*;
    use std::ffi::{c_char, c_int, c_uint, c_void, CStr};

    // sys/user.h — type tags for libprocstat's filestat.fs_type.
    const PS_FST_TYPE_VNODE:  c_int = 1;
    // sys/user.h — bits in filestat.fs_flags.
    const PS_FST_FFLAG_WRITE: c_int = 0x0002;
    // sys/sysctl.h — what selector for procstat_getprocs.
    const KERN_PROC_PID:      c_int = 1;

    /// Mirror of `struct filestat` from <libprocstat.h>.  Layout has
    /// been stable since FreeBSD 9.0 — fs_path's offset specifically
    /// is part of the public ABI surface.  We only read scalar fields
    /// and the path pointer; we never construct or modify one.
    #[repr(C)]
    struct FileStat {
        fs_type:       c_int,
        fs_flags:      c_int,
        fs_fflags:     c_int,
        fs_uflags:     c_int,
        fs_fd:         c_int,
        fs_ref_count:  c_int,
        fs_offset:     i64,
        fs_typedep:    *mut c_void,
        fs_path:       *mut c_char,
        // STAILQ_ENTRY(filestat) — next pointer at the tail.
        next_stqe_next: *mut FileStat,
    }

    /// Mirror of the STAILQ_HEAD that procstat_getfiles returns.
    #[repr(C)]
    struct FileStatList {
        stqh_first: *mut FileStat,
        stqh_last:  *mut *mut FileStat,
    }

    // libprocstat symbols.  kinfo_proc is opaque to us — we never
    // dereference it, just thread the pointer through the API.
    #[link(name = "procstat")]
    extern "C" {
        fn procstat_open_sysctl() -> *mut c_void;
        fn procstat_close(ps: *mut c_void);
        fn procstat_getprocs(
            ps: *mut c_void, what: c_int, arg: c_int, count: *mut c_uint,
        ) -> *mut c_void;       // returns kinfo_proc *
        fn procstat_freeprocs(ps: *mut c_void, p: *mut c_void);
        fn procstat_getfiles(
            ps: *mut c_void, p: *mut c_void, mmapped: c_int,
        ) -> *mut FileStatList;
        fn procstat_freefiles(ps: *mut c_void, head: *mut FileStatList);
    }

    pub fn read(pid: u32, limit: usize) -> Vec<PathBuf> {
        unsafe {
            let ps = procstat_open_sysctl();
            if ps.is_null() { return Vec::new(); }

            let mut count: c_uint = 0;
            let kproc = procstat_getprocs(ps, KERN_PROC_PID, pid as c_int, &mut count);
            if kproc.is_null() || count == 0 {
                procstat_close(ps);
                return Vec::new();
            }

            let head = procstat_getfiles(ps, kproc, 0);
            if head.is_null() {
                procstat_freeprocs(ps, kproc);
                procstat_close(ps);
                return Vec::new();
            }

            let mut out: Vec<PathBuf> = Vec::with_capacity(limit);
            let mut node = (*head).stqh_first;
            while !node.is_null() {
                if out.len() >= limit { break; }
                let f = &*node;
                // Vnode entries are the only ones that carry a real
                // filesystem path; pipes / sockets / kqueues have null
                // fs_path even when fs_flags includes WRITE.
                let writable = (f.fs_flags & PS_FST_FFLAG_WRITE) != 0;
                let is_vnode = f.fs_type == PS_FST_TYPE_VNODE;
                if writable && is_vnode && !f.fs_path.is_null() {
                    let cstr = CStr::from_ptr(f.fs_path);
                    if let Ok(s) = cstr.to_str() {
                        if !s.is_empty() && !s.starts_with("/dev/") {
                            out.push(PathBuf::from(s));
                        }
                    }
                }
                node = f.next_stqe_next;
            }

            procstat_freefiles(ps, head);
            procstat_freeprocs(ps, kproc);
            procstat_close(ps);
            out
        }
    }
}

// ── OpenBSD / NetBSD: stub ─────────────────────────────────────────────────
//
// kvm_getfiles returns inode + dev but no path; reconstructing paths
// would require walking every mounted filesystem and reverse-mapping
// inodes, which is both expensive and unreliable.  Out of scope.
#[cfg(any(target_os = "openbsd", target_os = "netbsd"))]
mod impl_ {
    use super::*;
    pub fn read(_pid: u32, _limit: usize) -> Vec<PathBuf> { Vec::new() }
}

// ── Anything else (illumos, Solaris, Haiku, ...) ───────────────────────────
#[cfg(not(any(
    target_os = "linux", target_os = "macos", windows,
    target_os = "freebsd", target_os = "dragonfly",
    target_os = "openbsd", target_os = "netbsd",
)))]
mod impl_ {
    use super::*;
    pub fn read(_pid: u32, _limit: usize) -> Vec<PathBuf> { Vec::new() }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use std::fs::OpenOptions;

    /// Cross-platform end-to-end test.  Opens a tempfile for writing
    /// and asserts our writable-FD enumerator finds it in the current
    /// process's open-file set.  Runs identically on Linux, macOS,
    /// and Windows; on FreeBSD it skips with a notice (no impl).
    #[test]
    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
    fn enumerates_self_open_writable_file() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();
        // Re-open with explicit write so the OS records us as writer.
        let mut f = OpenOptions::new().write(true).open(&path).unwrap();
        writeln!(f, "agtop-test").unwrap();
        f.sync_all().unwrap();
        // Keep `f` alive for the duration of the read.
        let pid = std::process::id();
        let files = read(pid, 4096);
        // Drop after enumeration so the assertion message can reference path.
        let canon_target = std::fs::canonicalize(&path).unwrap_or(path.clone());
        // Match either the raw or canonicalised path; macOS often
        // returns the resolved /private/var/folders/... path while
        // Linux / Windows return what was opened.
        let found = files.iter().any(|p| {
            p == &path || p == &canon_target
                // Substring fallback for cases like Windows long-path
                // prefixes / macOS /private/var symlink resolution.
                || p.to_string_lossy().contains(path.file_name().unwrap().to_str().unwrap())
        });
        drop(f);
        drop(tmp);
        assert!(found,
            "writing_files::read({}) did not include the test tempfile.\n\
             Opened: {:?}\n\
             Returned ({} entries):\n{}",
            pid, path, files.len(),
            files.iter().take(20).map(|p| format!("  {}", p.display())).collect::<Vec<_>>().join("\n"));
    }
}