Skip to content
Closed
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ members = [".", "plugins/nightcrow-recovery"]

[package]
name = "nightcrow"
version = "0.1.4"
version = "0.1.6"
edition = "2024"
rust-version = "1.89"
description = "Agent-adjacent terminal workbench — git diff viewer + multi-terminal panes for running CLIs next to your code"
Expand Down Expand Up @@ -88,4 +88,4 @@ ctrlc = "3"
# adding it directly here so the feature gate is explicit.
# `SetConsoleCtrlHandler` for `Win32_System_Console`: clearing the inherited
# ignore-Ctrl-C flag so panes get an interrupt, not just the 0x03 byte.
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ nightcrow update # reinstall the binary; restart the session afterwards

For foreground operation, use `nightcrow`; `nightcrow -d` starts the session in the background and writes its output to `~/.nightcrow/daemon.out`. See [Getting started](docs/getting-started.md) for installation variants, startup panes, disconnects, updates, and build verification.

To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, web and attach endpoints, attached clients, repositories, and panes. It exits non-zero when no daemon is running.
To inspect a running daemon without attaching, run `nightcrow status [--socket PATH]`. It performs a read-only one-shot query and reports the PID, version, start time, uptime, web and attach endpoints, attached clients, repositories, and panes. Fields the daemon marks unavailable are reported as unavailable; the command never infers state from the process table or port scans, never auto-starts a daemon, and never opens the attach TUI or changes session state.

Exit codes:

| Code | Meaning |
|------|---------|
| 0 | The daemon answered and its status was rendered. |
| 3 | Stopped — no socket or listener at the expected path. |
| 4 | Response timeout — the daemon accepted the connection but did not answer within 5 seconds. |
| 5 | Protocol error — the daemon answered, but the response failed validation or violated the status contract. |

The daemon socket is a Unix-domain socket on every platform: a filesystem path on Unix and an AF_UNIX socket via the `uds_windows` transport on Windows. `--socket PATH` overrides the default location on either platform.

## Features

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ trait TerminalBackend {
}
```

`PtyBackend`는 `portable-pty`와 reader/waiter thread로 로컬 child를 소유하고, `HubBackend`는 daemon hub에 요청만 보낸다. pane id·title·resize·reorder는 즉시 로컬 상태로 확정하지 않고 `Created`, `Resized`, `Reordered`, `Exited` 같은 backend event를 따른다. `drain_events`는 보고만 하며 `Exited`를 받은 owner가 `destroy_pane`을 호출해 자원을 회수한다. VT parsing은 두 backend 모두 client-side `PaneEmulator`가 담당한다. pane child의 환경은 daemon이 상속한 값이 아니라 pane이 실제로 렌더되는 emulator를 기준으로 맞춘다: `TERM=xterm-256color`, `COLORTERM=truecolor`를 강제하고 `NO_COLOR`는 제거한다. daemon은 agent shell이나 service manager처럼 터미널이 아닌 곳에서 시작될 수 있고, 그런 부모는 자기 자식용으로 `NO_COLOR=1`, `TERM=dumb`를 내보내는 일이 흔하기 때문이다.
`PtyBackend`는 `portable-pty`와 reader/waiter thread로 로컬 child를 소유하고, `HubBackend`는 daemon hub에 요청만 보낸다. 각 pane은 생성 직후 Unix session 또는 Windows Job Object를 종료 경계로 삼아 `destroy_pane`과 hub 종료가 그 경계 안의 subprocess를 함께 종료한다. 단순 detach·quit·browser disconnect는 pane destroy 경로를 호출하지 않으므로 프로세스를 유지한다. pane id·title·resize·reorder는 즉시 로컬 상태로 확정하지 않고 `Created`, `Resized`, `Reordered`, `Exited` 같은 backend event를 따른다. `drain_events`는 보고만 하며 `Exited`를 받은 owner가 `destroy_pane`을 호출해 자원을 회수한다. VT parsing은 두 backend 모두 client-side `PaneEmulator`가 담당한다. pane child의 환경은 daemon이 상속한 값이 아니라 pane이 실제로 렌더되는 emulator를 기준으로 맞춘다: `TERM=xterm-256color`, `COLORTERM=truecolor`를 강제하고 `NO_COLOR`는 제거한다. daemon은 agent shell이나 service manager처럼 터미널이 아닌 곳에서 시작될 수 있고, 그런 부모는 자기 자식용으로 `NO_COLOR=1`, `TERM=dumb`를 내보내는 일이 흔하기 때문이다.

세션 상한은 repository당 PTY 8개, pane 크기 1–500행 × 1–1100열, pane당 reconnect scrollback 256 KiB다. 명령 queue가 가득 찼다는 이유로 close/resize의 성공을 가정하지 않는다.
세션 상한은 repository당 PTY 8개, pane 크기 1–500행 × 1–1100열, pane당 reconnect scrollback 256 KiB다. close와 resize는 bounded input queue 밖의 전용 latest-state 경로로 보내 queue 포화에도 마지막 요청을 잃지 않는다.

## Shared state

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ TUI workspace state는 `~/.nightcrow/workspace.json`에 저장한다. 열린 pro

`ui::chrome::chrome_areas`가 project tabs, body, notice, hint 네 영역을 항상 만든다. notice와 hint는 배치와 무관하게 화면 아래 두 행이고(`bottom_rows`), project tabs는 `[layout] tabs`에 따라 그 위의 첫 행(`top`) 또는 좌측 `STRIP_WIDTH`(20) 열(`left`)이며 body는 남은 영역이다. body의 upper/lower split은 TUI layout config에서 계산하고, terminal pane rect는 [terminal.md](terminal.md)의 단일 기하 출처를 사용한다. notice나 dialog 때문에 행을 추가·삭제하지 않는다.

입력·PTY output·snapshot/load 결과·tree watch·resize·recovery·title 변화는 dirty frame을 요청한다. event loop는 16 ms마다 queue를 poll하지만 변경 없는 tick에는 `Terminal::draw`를 호출하지 않는다. `<leader> r`만 front buffer를 비우는 명시적 full repaint다. status의 hot-file fade와 attention/search caret 경계도 timer event로 dirty를 만든다.
입력·PTY output·snapshot/load 결과·tree watch·resize·recovery·title 변화는 dirty frame을 요청한다. event loop는 16 ms마다 queue를 poll하지만 변경 없는 tick에는 `Terminal::draw`를 호출하지 않는다. `<leader> r`만 front buffer를 비우는 명시적 full repaint다. status의 hot-file fade와 attention/search caret 경계도 timer event로 dirty를 만든다. repo 입력을 열면 notice row의 좌우 border가 고정된 채 accent/dim 두 pulse를 보여 주고, 두 pulse가 끝나면 dim으로 유지한다. 이 focus cue는 phase 경계에서만 repaint하며 완료 뒤 timer를 더 예약하지 않는다.

## Notice row

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ nightcrow update
nightcrow update --version 0.1.1
```

By default this downloads and verifies the latest binary from the official GitHub Release. Use `--version VER` to roll back to a published patch. Use `--path DIR` for a local checkout or `--git URL` for another source repository; those explicit development modes require Rust and run a locked, forced `cargo install`. Restart the session after updating so the daemon and its panes use the new binary. On Windows, `update` moves the installed executable aside before replacing it.
By default this downloads and verifies the latest binary from the official GitHub Release. Use `--version VER` to roll back to a published patch. Use `--path DIR` for a local checkout or `--git URL` for another source repository; those explicit development modes require Rust and run a locked, forced `cargo install`. Restart a running session after updating with `nightcrow stop`, then start nightcrow again so the daemon and its panes use the new binary. On Windows, `update` moves the installed executable aside before replacing it; if that parked copy is still in use, cleanup waits for the session or updater to exit. The new binary is already installed, so a second `update` is not needed.

## Building and testing

Expand Down
2 changes: 1 addition & 1 deletion plugins/nightcrow-recovery/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "nightcrow-recovery"
version = "0.1.4"
version = "0.1.6"
edition = "2024"
rust-version = "1.89"
description = "nightcrow plugin: notices a coding CLI hit its usage limit and resumes it when the limit resets"
Expand Down
5 changes: 4 additions & 1 deletion src/application/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use crate::application::terminal_guard::TuiTerminal;
use crate::workspace::Workspace;
use crossterm::event::{self, Event};
use ratatui::layout::Rect;
use std::time::{Duration, SystemTime};
use std::time::{Duration, Instant, SystemTime};
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;

Expand Down Expand Up @@ -123,6 +123,9 @@ pub(crate) fn main_loop(
.active()
.is_some_and(crate::app::App::search_overlay_active);
redraw.observe_caret(caret_active, crate::ui::current_caret_lit());
if ws.advance_repo_input_focus_flash(Instant::now()) {
redraw.request(RedrawCause::RepoInputFocus);
}
let active_tab = ws.active_index();
let empty_notice = ws.empty_notice().cloned();
let prefix_armed = ws.prefix_armed();
Expand Down
2 changes: 2 additions & 0 deletions src/application/redraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub(crate) enum RedrawCause {
Log,
AttentionBlink,
CaretBlink,
RepoInputFocus,
HotFile,
Session,
Redraw,
Expand Down Expand Up @@ -124,6 +125,7 @@ mod tests {
RedrawCause::Git,
RedrawCause::Log,
RedrawCause::HotFile,
RedrawCause::RepoInputFocus,
RedrawCause::Session,
RedrawCause::Redraw,
];
Expand Down
4 changes: 2 additions & 2 deletions src/application/tests/prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ fn leader_x_asks_the_workspace_to_close_the_project() {
let mut app = app_with_files(vec!["a.rs"]);
let _ = handle_key(&mut app, leader());

let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::NONE));
let outcome = handle_key(&mut app, press(KeyCode::Char('x'), KeyModifiers::CONTROL));

assert_eq!(outcome, KeyOutcome::Project(ProjectRequest::Close));
assert!(
Expand Down Expand Up @@ -208,7 +208,7 @@ fn handle_key_leader_w_closes_pane_with_terminal_focus() {
app.terminal.create_pane_now().unwrap();
let before = app.terminal.panes.len();
let _ = handle_key(&mut app, leader());
let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::NONE));
let _ = handle_key(&mut app, press(KeyCode::Char('w'), KeyModifiers::CONTROL));
// Closing is a request; the pane goes when its exit arrives.
app.poll_terminal();
assert_eq!(app.terminal.panes.len(), before - 1);
Expand Down
10 changes: 9 additions & 1 deletion src/backend/pty.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::slot::{PaneSlot, PaneSlots};
use super::{BackendEvent, PaneId, ResizeOutcome, TerminalBackend};
use crate::config::ShellConfig;
use crate::platform::process_tree::ProcessTree;
use crate::platform::threading::try_timed_join;
use anyhow::Result;
use portable_pty::PtySize;
Expand Down Expand Up @@ -56,6 +57,7 @@ pub(super) struct PtyPane {
pub(super) master: Option<Box<dyn portable_pty::MasterPty>>,
pub(super) writer: Option<Box<dyn Write + Send>>,
pub(super) killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
pub(super) process_tree: ProcessTree,
pub(super) rx: Receiver<PtyEvent>,
pub(super) reader_handle: Option<thread::JoinHandle<()>>,
pub(super) wait_handle: Option<thread::JoinHandle<()>>,
Expand All @@ -64,7 +66,13 @@ pub(super) struct PtyPane {

impl Drop for PtyPane {
fn drop(&mut self) {
// Best-effort kill: the child may already be gone.
// Terminate the pane's process boundary before the PTY handles and
// waiter are reaped, so child jobs cannot outlive an explicit close.
if let Err(error) = self.process_tree.terminate() {
tracing::warn!(%error, "could not terminate pane process tree");
}
// Best-effort direct kill covers a child that exited before its tree
// termination raced with teardown.
let _ = self.killer.kill();
// Drop writer/master so the reader's blocked `read()` returns EOF;
// without this, joining the reader would hang.
Expand Down
12 changes: 12 additions & 0 deletions src/backend/pty_spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use super::{ExitPhase, PtyBackend, PtyEvent, PtyPane};
use crate::backend::PaneId;
use crate::backend::identity::{PANE_TOKEN_ENV, PLUGIN_RUNTIME_DIR_ENV, PaneIdentity};
use crate::backend::slot::{PaneLaunch, resume_command_line};
use crate::platform::process_tree::ProcessTree;
use anyhow::Result;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::io::Read;
Expand Down Expand Up @@ -111,6 +112,16 @@ impl PtyBackend {
cmd.cwd(canonical);
}
let mut child = pair.slave.spawn_command(cmd)?;
let process_tree = match ProcessTree::attach(&*child, &*pair.master) {
Ok(tree) => tree,
Err(error) => {
// Do not leave a process behind when establishing the tree
// boundary fails before the pane is inserted in the backend.
let _ = child.kill();
let _ = child.wait();
return Err(error.context("establishing pane process tree"));
}
};
let killer = child.clone_killer();
drop(pair.slave);

Expand Down Expand Up @@ -154,6 +165,7 @@ impl PtyBackend {
master: Some(pair.master),
writer: Some(writer),
killer,
process_tree,
rx,
reader_handle: Some(reader_handle),
wait_handle: Some(wait_handle),
Expand Down
106 changes: 106 additions & 0 deletions src/backend/pty_tests/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use super::*;

const DESCENDANT_START_DEADLINE: Duration = Duration::from_secs(30);

#[test]
fn pty_backend_create_and_destroy_pane() {
let mut backend = PtyBackend::new(".", ShellConfig::default());
Expand All @@ -9,6 +11,110 @@ fn pty_backend_create_and_destroy_pane() {
assert!(!backend.panes.contains_key(&id));
}

#[test]
fn destroying_a_pane_terminates_its_subprocess() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("child.pid");
let (shell, command) = descendant_command(&marker);
let mut backend = PtyBackend::new(".", shell);
let id = backend
.open_pane(24, 80, Some(&command))
.expect("open pane with descendant");
let pid = wait_for_pid(&mut backend, id, &marker);

backend.destroy_pane(id);

let deadline = Instant::now() + Duration::from_secs(5);
while process_is_alive(pid) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(20));
}
assert!(
!process_is_alive(pid),
"pane descendant {pid} survived close"
);
}

#[cfg(unix)]
fn descendant_command(marker: &std::path::Path) -> (ShellConfig, String) {
(
ShellConfig::default(),
format!("sh -c 'echo $$ > \"{}\"; exec sleep 60'", marker.display()),
)
}

#[cfg(windows)]
fn descendant_command(marker: &std::path::Path) -> (ShellConfig, String) {
let shell = ShellConfig {
program: Some("powershell.exe".to_string()),
command_args: vec!["-NoProfile".to_string(), "-Command".to_string()],
};
let command = format!(
"$p = Start-Process cmd.exe -WindowStyle Hidden -ArgumentList '/C','ping -t 127.0.0.1' -PassThru; [IO.File]::WriteAllText('{}', [string]$p.Id); $p.WaitForExit()",
marker.display()
);
(shell, command)
}

fn wait_for_pid(backend: &mut PtyBackend, id: PaneId, marker: &std::path::Path) -> u32 {
let deadline = Instant::now() + DESCENDANT_START_DEADLINE;
let mut output = Vec::new();
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(marker)
&& let Ok(pid) = text.trim().parse()
{
return pid;
}
for event in backend.drain_events() {
if let BackendEvent::Output { pane, data } = event
&& pane == id
{
if data.windows(4).any(|window| window == b"\x1b[6n") {
let _ = backend.send_input(id, b"\x1b[1;1R");
}
output.extend(data);
}
}
thread::sleep(Duration::from_millis(20));
}
panic!(
"subprocess did not write {}: {}",
marker.display(),
String::from_utf8_lossy(&output)
);
}

#[cfg(any(target_os = "linux", target_os = "android"))]
fn process_is_alive(pid: u32) -> bool {
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat"))
&& stat.rfind(')').and_then(|end| stat.as_bytes().get(end + 2)) == Some(&b'Z')
{
return false;
}
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
fn process_is_alive(pid: u32) -> bool {
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}

#[cfg(windows)]
fn process_is_alive(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject};

const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000;
let process = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) };
if process.is_null() {
return false;
}
let alive = unsafe { WaitForSingleObject(process, 0) } == WAIT_TIMEOUT;
unsafe { CloseHandle(process) };
alive
}

#[test]
fn resizing_an_unknown_pane_is_reported() {
let mut backend = PtyBackend::new(".", ShellConfig::default());
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ mod attach;
mod daemon;
mod init;
pub(crate) mod plugin_cmd;
mod status;
pub(crate) mod status;
mod stop;
mod update;

Expand Down
Loading