From e5c1d94e0f3d7a0ff2f6a3eb07122536df3c4b18 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 08:17:48 +0900 Subject: [PATCH 1/9] docs: load checkout-local agent guidance --- .gitignore | 2 +- AGENTS.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7dc1c143..73b1fbef 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,7 @@ /viewer-ui/tsconfig.tsbuildinfo # Agent tool scratch state /.atl/ -/.worktress/ +/.worktrees/ # Checkout-local agent instructions /AGENTS.local.md diff --git a/AGENTS.md b/AGENTS.md index 19226aa4..a45acb85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # nightcrow +체크아웃 루트에 `AGENTS.local.md`가 있으면 이 문서와 함께 읽고 적용한다. + Agent-adjacent Rust TUI: 상단은 git diff/commit log 뷰어, 하단은 split-view 멀티 터미널 패널. 설계는 `docs/architecture.md`, 사용법은 `README.md`. From 5f17b4c86c759a6d19c6fc44ac8ee5820db2caca Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 08:17:53 +0900 Subject: [PATCH 2/9] fix(cli): wait for daemon shutdown acknowledgement --- src/cli/stop.rs | 79 +++++++++++++++++------ src/cli/stop_tests.rs | 147 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 src/cli/stop_tests.rs diff --git a/src/cli/stop.rs b/src/cli/stop.rs index ab524a01..b2c2aaac 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -1,11 +1,18 @@ use anyhow::{Context, Result}; -use std::io::Write; +use std::io::{Read, Write}; use std::path::PathBuf; +use std::time::{Duration, Instant}; -use crate::daemon::frame::{Frame, read_frame, write_frame}; -use crate::daemon::protocol::ClientMessage; +use crate::daemon::frame::{Frame, FrameKind, read_frame, write_frame}; +use crate::daemon::protocol::{ClientMessage, ServerMessage}; use crate::daemon::transport::UnixStream; +// The daemon's cleanup normally takes milliseconds, but a configured plugin may +// take up to 200 ms per host before it is force-killed. Keep room for the +// bounded cleanup of a full configured session while still rejecting a lost +// request instead of waiting forever. +const SHUTDOWN_ACK_TIMEOUT: Duration = Duration::from_secs(20); + /// Send a graceful shutdown request to a running daemon. pub(crate) fn run_stop(socket: Option) -> Result<()> { let path = match socket { @@ -29,23 +36,59 @@ pub(crate) fn run_stop(socket: Option) -> Result<()> { serde_json::to_vec(&ClientMessage::Shutdown).context("encoding the shutdown request")?; write_frame(&mut stream, &Frame::control(json)).context("sending the shutdown request")?; stream.flush().context("flushing the shutdown request")?; + stream + .set_read_timeout(Some(SHUTDOWN_ACK_TIMEOUT)) + .context("setting the shutdown acknowledgment timeout")?; - // Closing the connection is the daemon's acknowledgment. A reset is also - // expected when shutdown wins the race with this read. - if let Err(err) = read_frame(&mut stream) { - let expected_disconnect = err.downcast_ref::().is_some_and(|error| { - matches!( - error.kind(), - std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::ConnectionAborted - | std::io::ErrorKind::UnexpectedEof - ) - }); - if !expected_disconnect { - return Err(err).context("waiting for the daemon to acknowledge the shutdown"); - } - } + wait_for_shutdown_ack(&mut stream, Instant::now() + SHUTDOWN_ACK_TIMEOUT)?; println!("nightcrow: daemon is shutting down"); Ok(()) } + +/// Consume unsolicited frames until the daemon closes this connection. +/// +/// An attach socket speaks first, so a `Repos` or terminal frame may be ahead of +/// the shutdown request's outcome. Only EOF, or a reset/abort while the daemon +/// is closing, proves that shutdown has reached the daemon's exit path. +fn wait_for_shutdown_ack(reader: &mut R, deadline: Instant) -> Result<()> { + loop { + if Instant::now() >= deadline { + anyhow::bail!("timed out waiting for the daemon to acknowledge the shutdown"); + } + let frame = match read_frame(reader) { + Ok(frame) => frame, + Err(err) if expected_disconnect(&err) => return Ok(()), + Err(err) => { + return Err(err).context("waiting for the daemon to acknowledge the shutdown"); + } + }; + let Some(frame) = frame else { + return Ok(()); + }; + + if frame.kind != FrameKind::Control { + continue; + } + let message: ServerMessage = serde_json::from_slice(&frame.payload) + .context("decoding a daemon response while waiting for shutdown")?; + if let ServerMessage::Error { message } = message { + anyhow::bail!("daemon rejected the shutdown request: {message}"); + } + } +} + +fn expected_disconnect(err: &anyhow::Error) -> bool { + err.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::UnexpectedEof + ) + }) +} + +#[cfg(test)] +#[path = "stop_tests.rs"] +mod tests; diff --git a/src/cli/stop_tests.rs b/src/cli/stop_tests.rs new file mode 100644 index 00000000..88cc4a5f --- /dev/null +++ b/src/cli/stop_tests.rs @@ -0,0 +1,147 @@ +use super::*; +use crate::daemon::frame::write_frame; +use std::io::{self, Cursor, Read}; +use std::time::{Duration, Instant}; + +fn encoded(frame: &Frame) -> Vec { + let mut bytes = Vec::new(); + write_frame(&mut bytes, frame).expect("encode frame"); + bytes +} + +fn response(message: &ServerMessage) -> Frame { + Frame::control(serde_json::to_vec(message).expect("encode response")) +} + +fn unsolicited_frames() -> Vec { + let mut bytes = encoded(&response(&ServerMessage::Repos { + repos: Vec::new(), + active: None, + accent: 0, + })); + bytes.extend(encoded(&Frame::terminal(b"pane output".to_vec()))); + bytes.extend(encoded(&response(&ServerMessage::Hello { + version: "test".into(), + client: 1, + }))); + bytes +} + +#[test] +fn unsolicited_frames_are_consumed_before_clean_eof() { + assert!( + wait_for_shutdown_ack(&mut Cursor::new(unsolicited_frames()), future_deadline()).is_ok() + ); +} + +#[test] +fn unsolicited_frames_are_consumed_before_a_read_timeout() { + let mut reader = TrailingErrorReader { + data: Cursor::new(unsolicited_frames()), + kind: io::ErrorKind::TimedOut, + }; + + let error = + wait_for_shutdown_ack(&mut reader, future_deadline()).expect_err("timeout is not an ack"); + assert!( + error + .to_string() + .contains("waiting for the daemon to acknowledge the shutdown") + ); + assert!( + reader.exhausted(), + "every unsolicited frame must be consumed" + ); +} + +#[test] +fn daemon_error_is_a_failed_shutdown_acknowledgment() { + let bytes = encoded(&response(&ServerMessage::Error { + message: "old daemon rejected shutdown".into(), + })); + + let error = wait_for_shutdown_ack(&mut Cursor::new(bytes), future_deadline()) + .expect_err("error response"); + assert!(error.to_string().contains("old daemon rejected shutdown")); +} + +#[test] +fn clean_eof_is_a_shutdown_acknowledgment() { + assert!(wait_for_shutdown_ack(&mut Cursor::new(Vec::new()), future_deadline()).is_ok()); +} + +#[test] +fn reset_and_abort_are_shutdown_acknowledgments() { + for kind in [ + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + ] { + let mut reader = FailingReader { kind }; + assert!( + wait_for_shutdown_ack(&mut reader, future_deadline()).is_ok(), + "{kind:?}" + ); + } +} + +#[test] +fn unrelated_socket_errors_are_failures() { + let mut reader = FailingReader { + kind: io::ErrorKind::PermissionDenied, + }; + + let error = + wait_for_shutdown_ack(&mut reader, future_deadline()).expect_err("unrelated socket error"); + assert!( + error + .to_string() + .contains("waiting for the daemon to acknowledge the shutdown") + ); +} + +#[test] +fn an_expired_shutdown_ack_deadline_is_a_failure() { + let error = wait_for_shutdown_ack(&mut Cursor::new(Vec::new()), Instant::now()) + .expect_err("expired deadline"); + + assert!( + error + .to_string() + .contains("timed out waiting for the daemon to acknowledge the shutdown") + ); +} + +fn future_deadline() -> Instant { + Instant::now() + Duration::from_secs(1) +} + +struct TrailingErrorReader { + data: Cursor>, + kind: io::ErrorKind, +} + +impl TrailingErrorReader { + fn exhausted(&self) -> bool { + self.data.position() == self.data.get_ref().len() as u64 + } +} + +impl Read for TrailingErrorReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.exhausted() { + Err(io::Error::new(self.kind, "trailing test socket error")) + } else { + self.data.read(buf) + } + } +} + +struct FailingReader { + kind: io::ErrorKind, +} + +impl Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + Err(io::Error::new(self.kind, "test socket error")) + } +} From 6a8a8d227b5a52e1a4caf06bbe0e57f09e227834 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 08:47:54 +0900 Subject: [PATCH 3/9] docs: stop hard-wrapping Markdown prose --- .markdownlint-cli2.jsonc | 5 + docs/README.md | 4 +- docs/architecture.md | 88 +--- docs/architecture/git-views.md | 156 ++----- docs/architecture/plugin-host.md | 209 ++------- docs/architecture/session.md | 517 +++++----------------- docs/architecture/terminal.md | 191 ++------ docs/architecture/ui.md | 269 +++--------- docs/architecture/web.md | 733 +++++-------------------------- docs/configuration.md | 63 +-- docs/decisions.md | 234 +++------- docs/getting-started.md | 106 +---- docs/keybindings.md | 111 ++--- docs/plugins.md | 60 +-- docs/projects.md | 35 +- docs/session-state.md | 54 +-- docs/views.md | 102 +---- docs/web-viewer.md | 377 ++++------------ 18 files changed, 647 insertions(+), 2667 deletions(-) create mode 100644 .markdownlint-cli2.jsonc diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 00000000..24852087 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,5 @@ +{ + "config": { + "MD013": false + } +} diff --git a/docs/README.md b/docs/README.md index a62fad4b..5b2b7cf7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,6 @@ # nightcrow documentation -The [top-level README](../README.md) is the tour: what nightcrow is, how to -install it, and enough usage to get a session up. Everything past that lives -here, one page per surface. +The [top-level README](../README.md) is the tour: what nightcrow is, how to install it, and enough usage to get a session up. Everything past that lives here, one page per surface. ## Using nightcrow diff --git a/docs/architecture.md b/docs/architecture.md index c6c049f4..4ebdea79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,39 +1,20 @@ # nightcrow Architecture -이 문서는 색인이다. 전체 그림과 불변식만 담고, 각 영역의 상세 설계는 `docs/architecture/` 아래 -하위 문서로 나뉘어 있다 — 맨 아래 [Detailed design](#detailed-design) 표를 보라. +이 문서는 색인이다. 전체 그림과 불변식만 담고, 각 영역의 상세 설계는 `docs/architecture/` 아래 하위 문서로 나뉘어 있다 — 맨 아래 [Detailed design](#detailed-design) 표를 보라. ## Overview -nightcrow는 **세션 데몬 하나 + 프론트엔드 N개** 구조의 agent-adjacent Rust 애플리케이션이다. -`nightcrow`가 세션(저장소 집합과 터미널)을 소유하고, 터미널에서 `nightcrow attach`로, 브라우저에서 -웹으로 같은 세션에 붙는다. 클라이언트가 나가도 세션은 산다. 화면은 상단 패널에서 git diff를 -실시간 추적하고, 하단 패널에서 임의의 프로세스(주로 LLM CLI나 빌드/테스트 러너)를 동시에 실행한다. +nightcrow는 **세션 데몬 하나 + 프론트엔드 N개** 구조의 agent-adjacent Rust 애플리케이션이다. `nightcrow`가 세션(저장소 집합과 터미널)을 소유하고, 터미널에서 `nightcrow attach`로, 브라우저에서 웹으로 같은 세션에 붙는다. 클라이언트가 나가도 세션은 산다. 화면은 상단 패널에서 git diff를 실시간 추적하고, 하단 패널에서 임의의 프로세스(주로 LLM CLI나 빌드/테스트 러너)를 동시에 실행한다. -nightcrow 자체는 AI에 대한 ontology를 갖지 않는다 — agent든 사람이든 동일한 PTY와 파일 mtime을 -본다. provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세션을 재개하는 것)이 필요하면 -코어가 아니라 **plugin**이 갖는다. 코어는 pane을 외부 프로세스에 보여주고 그 프로세스가 요청한 -것을 검증할 뿐, 어떤 CLI가 무엇을 출력하는지는 끝까지 모른다 — -[plugin-host.md](architecture/plugin-host.md) 참고. +nightcrow 자체는 AI에 대한 ontology를 갖지 않는다 — agent든 사람이든 동일한 PTY와 파일 mtime을 본다. provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세션을 재개하는 것)이 필요하면 코어가 아니라 **plugin**이 갖는다. 코어는 pane을 외부 프로세스에 보여주고 그 프로세스가 요청한 것을 검증할 뿐, 어떤 CLI가 무엇을 출력하는지는 끝까지 모른다 — [plugin-host.md](architecture/plugin-host.md) 참고. -**대상 사용자**: 터미널 중심으로 작업하면서, 옆 패널의 LLM CLI(Claude Code, Codex, aider 등)나 -빌드/테스트 러너가 만든 코드 변경을 실시간으로 따라잡고 싶은 개발자. +**대상 사용자**: 터미널 중심으로 작업하면서, 옆 패널의 LLM CLI(Claude Code, Codex, aider 등)나 빌드/테스트 러너가 만든 코드 변경을 실시간으로 따라잡고 싶은 개발자. -**핵심 기능**: 멀티 프로젝트 탭(최대 10개 저장소), 변경 파일 리스트 + git diff 뷰어(문법 -하이라이팅), commit log 뷰, read-only 파일 트리 내비게이터(라이브 워치 + 재귀 파일명 검색 + -마크다운·HTML 렌더 뷰), split-view 멀티 PTY 패널, mtime 기반 hot-file 강조 + idle auto-follow, -OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 라우팅, 클릭 가능한 힌트 바). +**핵심 기능**: 멀티 프로젝트 탭(최대 10개 저장소), 변경 파일 리스트 + git diff 뷰어(문법 하이라이팅), commit log 뷰, read-only 파일 트리 내비게이터(라이브 워치 + 재귀 파일명 검색 + 마크다운·HTML 렌더 뷰), split-view 멀티 PTY 패널, mtime 기반 hot-file 강조 + idle auto-follow, OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 라우팅, 클릭 가능한 힌트 바). -**웹 표면**: 같은 git 데이터를 DOM으로 렌더하고 세션의 터미널을 서빙하는 웹 뷰어(`[web_viewer]`). -세션의 일부라 항상 뜨며, attach 소켓과 인증 방식이 다르다 — 소켓은 파일 권한, 웹은 Argon2 로그인. +**웹 표면**: 같은 git 데이터를 DOM으로 렌더하고 세션의 터미널을 서빙하는 웹 뷰어(`[web_viewer]`). 세션의 일부라 항상 뜨며, attach 소켓과 인증 방식이 다르다 — 소켓은 파일 권한, 웹은 Argon2 로그인. -**두 표면은 기능적으로 동일하다**. viewer와 attached TUI는 같은 세션에 붙은 클라이언트이므로, -한쪽에만 있는 기능은 "이 도구가 무엇을 할 수 있는가"를 어느 화면을 보느냐에 따라 달라지게 만든다. -다만 **구현 방식은 갈라질 수 있고, 갈라지는 것이 자연스럽지 않으면 구현하지 않을 수도 있다** — -터미널과 브라우저는 입력·기하·수명이 다르기 때문이다. 갈라질 때는 무엇을 포기했는지 남긴다. -예를 들어 accent와 pane zoom은 세션이 공유하지만 `upper_pct`는 공유하지 않는다: 퍼센트가 터미널과 -브라우저에서 다른 크기를 뜻하기 때문이며, 이유는 `src/session/prefs/`에 적혀 있다. 반대로 한쪽만 -되는 것이 결함인 경우가 더 많다 — 삭제된 파일의 diff는 TUI에서 줄곧 됐고 viewer만 400이었다. +**두 표면은 기능적으로 동일하다**. viewer와 attached TUI는 같은 세션에 붙은 클라이언트이므로, 한쪽에만 있는 기능은 "이 도구가 무엇을 할 수 있는가"를 어느 화면을 보느냐에 따라 달라지게 만든다. 다만 **구현 방식은 갈라질 수 있고, 갈라지는 것이 자연스럽지 않으면 구현하지 않을 수도 있다** — 터미널과 브라우저는 입력·기하·수명이 다르기 때문이다. 갈라질 때는 무엇을 포기했는지 남긴다. 예를 들어 accent와 pane zoom은 세션이 공유하지만 `upper_pct`는 공유하지 않는다: 퍼센트가 터미널과 브라우저에서 다른 크기를 뜻하기 때문이며, 이유는 `src/session/prefs/`에 적혀 있다. 반대로 한쪽만 되는 것이 결함인 경우가 더 많다 — 삭제된 파일의 diff는 TUI에서 줄곧 됐고 viewer만 400이었다. ## Layout @@ -55,21 +36,15 @@ OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 크롬 행 불변식 셋: -- **네 행 분할은 `ui::chrome::chrome_rows` 한 곳에서만 계산된다.** `draw`와 세 개의 geometry - helper(PTY 사이저, upper-panel/hint-bar hit test)가 정확히 같은 셀에 떨어져야 하므로, 손으로 - 복사된 분할이 어긋나면 터미널 크기가 틀어지거나 모든 마우스 클릭이 한 행씩 밀린다. -- **프로젝트 탭 행은 탭 개수와 무관하게 항상 존재한다.** 행이 생겼다 사라지면 프로젝트를 열고 닫을 - 때마다 모든 PTY가 resize되는데, notice row를 별도 행이 아닌 오버레이로 둔 것과 같은 이유다. -- **탭 행과 notice row는 `draw`의 레이아웃 분기 이전에 렌더된다.** fullscreen에서 탭이 사라지면 - 사용자가 어느 프로젝트에 있는지 알 수 없어지므로, 분기마다 중복 렌더하는 대신 구조로 보장한다. +- **네 행 분할은 `ui::chrome::chrome_rows` 한 곳에서만 계산된다.** `draw`와 세 개의 geometry helper(PTY 사이저, upper-panel/hint-bar hit test)가 정확히 같은 셀에 떨어져야 하므로, 손으로 복사된 분할이 어긋나면 터미널 크기가 틀어지거나 모든 마우스 클릭이 한 행씩 밀린다. +- **프로젝트 탭 행은 탭 개수와 무관하게 항상 존재한다.** 행이 생겼다 사라지면 프로젝트를 열고 닫을 때마다 모든 PTY가 resize되는데, notice row를 별도 행이 아닌 오버레이로 둔 것과 같은 이유다. +- **탭 행과 notice row는 `draw`의 레이아웃 분기 이전에 렌더된다.** fullscreen에서 탭이 사라지면 사용자가 어느 프로젝트에 있는지 알 수 없어지므로, 분기마다 중복 렌더하는 대신 구조로 보장한다. -하단 패널은 탭 전환이 아니라 balanced grid로 *보이는* 모든 pane을 동시에 그린다 — -[terminal.md](architecture/terminal.md) 참고. +하단 패널은 탭 전환이 아니라 balanced grid로 *보이는* 모든 pane을 동시에 그린다 — [terminal.md](architecture/terminal.md) 참고. ## Module Structure -모든 소스 파일은 300줄 이하(LOC 규칙, `.agents/rules/guardrails.md` 참고). 테스트는 -`#[cfg(test)] mod tests;`로 별도 파일/디렉터리에 분리한다(아래 트리에서는 생략). +모든 소스 파일은 300줄 이하(LOC 규칙, `.agents/rules/guardrails.md` 참고). 테스트는 `#[cfg(test)] mod tests;`로 별도 파일/디렉터리에 분리한다(아래 트리에서는 생략). ``` src/ @@ -202,50 +177,29 @@ src/ | 웹 뷰어 번들 임베드 | rust-embed 8.12 (`viewer-ui/dist`) | | 웹 뷰어 프론트엔드 | React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6, 마크다운은 react-markdown 10(+remark-gfm, rehype-highlight), 테스트는 vitest 4 | -`toml_edit`는 생성한 웹 비밀번호만 바꾸면서 기존 TOML의 주석·공백·키 순서를 보존하려고 쓴다. -`toml`로 전체 문서를 다시 직렬화하거나 문자열을 직접 고치는 대안은 서식을 잃거나 동등한 table 표현을 -빠뜨릴 수 있어 채택하지 않았다. `tempfile`은 상태와 설정을 대상 디렉터리의 충돌 방지 임시 파일에 쓴 뒤 -`persist`로 교체하는 데 쓴다. 같은 디렉터리를 쓰면 부분 기록과 파일시스템 간 이동 위험을 줄이지만, -교체의 원자성은 파일시스템과 플랫폼에 달려 있다. `std::fs`만 쓰는 대안은 고유 이름의 배타적 생성, -실패 시 정리, Windows 교체 동작을 직접 구현해야 하므로 채택하지 않았다. +`toml_edit`는 생성한 웹 비밀번호만 바꾸면서 기존 TOML의 주석·공백·키 순서를 보존하려고 쓴다. `toml`로 전체 문서를 다시 직렬화하거나 문자열을 직접 고치는 대안은 서식을 잃거나 동등한 table 표현을 빠뜨릴 수 있어 채택하지 않았다. `tempfile`은 상태와 설정을 대상 디렉터리의 충돌 방지 임시 파일에 쓴 뒤 `persist`로 교체하는 데 쓴다. 같은 디렉터리를 쓰면 부분 기록과 파일시스템 간 이동 위험을 줄이지만, 교체의 원자성은 파일시스템과 플랫폼에 달려 있다. `std::fs`만 쓰는 대안은 고유 이름의 배타적 생성, 실패 시 정리, Windows 교체 동작을 직접 구현해야 하므로 채택하지 않았다. -PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다. 초기에는 tmux control-mode -백엔드(`TmuxBackend`)도 병행 지원했으나, 중첩 TUI 키보드 라우팅 문제를 leader(prefix) 모델로 -해결하면서 tmux 의존성 없이 `PtyBackend`만으로 충분해져 제거했다. +PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다. 초기에는 tmux control-mode 백엔드(`TmuxBackend`)도 병행 지원했으나, 중첩 TUI 키보드 라우팅 문제를 leader(prefix) 모델로 해결하면서 tmux 의존성 없이 `PtyBackend`만으로 충분해져 제거했다. ## Critical Risk -**중첩 TUI 키보드 라우팅**: Claude Code, Codex 등 LLM CLI는 자체 TUI를 가진다. Ratatui 레이어와 -내부 TUI 간 키보드 이벤트 충돌은 leader(prefix) 모델로 회피한다. 앱 전역 명령은 leader(기본 -`Ctrl+F`) 뒤의 한 키로만 실행되고, 그 외 모든 키(단독 Ctrl 포함)는 raw key 그대로 PTY로 -전달된다(`input::encode_key`). 이로써 `Ctrl+W`/`Ctrl+L` 등 프롬프트 편집 Ctrl 키가 nightcrow에 -가로채이지 않고 내부 프로그램에 도달한다. leader와 충돌하지 않는 예약키는 modifier -필수(Shift+arrow/PgUp/PgDn) 또는 F-key(F1–F10)로 제한해, 터미널마다 일관되게 식별되고 프롬프트 -텍스트와 섞이지 않는다. 상세는 [ui.md](architecture/ui.md#keyboard-routing). +**중첩 TUI 키보드 라우팅**: Claude Code, Codex 등 LLM CLI는 자체 TUI를 가진다. Ratatui 레이어와 내부 TUI 간 키보드 이벤트 충돌은 leader(prefix) 모델로 회피한다. 앱 전역 명령은 leader(기본 `Ctrl+F`) 뒤의 한 키로만 실행되고, 그 외 모든 키(단독 Ctrl 포함)는 raw key 그대로 PTY로 전달된다(`input::encode_key`). 이로써 `Ctrl+W`/`Ctrl+L` 등 프롬프트 편집 Ctrl 키가 nightcrow에 가로채이지 않고 내부 프로그램에 도달한다. leader와 충돌하지 않는 예약키는 modifier 필수(Shift+arrow/PgUp/PgDn) 또는 F-key(F1–F10)로 제한해, 터미널마다 일관되게 식별되고 프롬프트 텍스트와 섞이지 않는다. 상세는 [ui.md](architecture/ui.md#keyboard-routing). ## Development History - 프로젝트 골격: 상단 파일 리스트 + diff 뷰어, git2 기반 변경 파일/diff 파이프라인 -- 멀티 터미널: `TerminalBackend` trait 도입, `TmuxBackend` → `PtyBackend` 단일화, 중첩 TUI 키보드 - 라우팅을 leader 모델로 정리 +- 멀티 터미널: `TerminalBackend` trait 도입, `TmuxBackend` → `PtyBackend` 단일화, 중첩 TUI 키보드 라우팅을 leader 모델로 정리 - 릴리스 준비: `config.toml` 설정 시스템, 파일 로깅(rotation + retention), clippy/audit clean, CI -- 터미널 확장: split-view grid, fullscreen 3-state 사이클, pane swap, layout-aware jump digit, - 프로그램 모드 기반 scroll/mouse routing, 클릭 가능한 힌트 바·탭 바 +- 터미널 확장: split-view grid, fullscreen 3-state 사이클, pane swap, layout-aware jump digit, 프로그램 모드 기반 scroll/mouse routing, 클릭 가능한 힌트 바·탭 바 - 터미널 에뮬레이터 교체: vt100 → alacritty_terminal(쿼리 응답, resize reflow, wide-char 크래시) - 멀티 프로젝트: 저장소 10개를 탭으로(F1–F10), 세션을 `~/.nightcrow/workspace.json`으로 통합 -- 웹 뷰어(`[web_viewer]` / `nightcrow serve`): 같은 git 데이터를 DOM으로 렌더하는 두 번째 - 프론트엔드. 이후 commit 드릴다운, diff split, 마크다운·HTML 렌더 뷰, hot-file 강조, 서버 저장 - preference, 클론, 폰 레이아웃으로 확장 -- 세션 데몬 전환: 데몬이 세션을 소유하고 TUI·브라우저가 클라이언트가 됐다. 화면을 반사하던 - `[web_mirror]` 서버는 반사할 대상이 없어져 제거했다 — 배경은 [decisions.md](decisions.md) +- 웹 뷰어(`[web_viewer]` / `nightcrow serve`): 같은 git 데이터를 DOM으로 렌더하는 두 번째 프론트엔드. 이후 commit 드릴다운, diff split, 마크다운·HTML 렌더 뷰, hot-file 강조, 서버 저장 preference, 클론, 폰 레이아웃으로 확장 +- 세션 데몬 전환: 데몬이 세션을 소유하고 TUI·브라우저가 클라이언트가 됐다. 화면을 반사하던 `[web_mirror]` 서버는 반사할 대상이 없어져 제거했다 — 배경은 [decisions.md](decisions.md) ## Future Refactor Notes -- 저장소별 상태는 `GitViewManager`와 그 안의 `RepositoryView`로 분리됐다. `App`은 terminal/focus/ - fullscreen/notice/interaction을 소유하고 명시적 façade로 UI·입력 계층에 저장소 상태를 제공한다. - 이후 분리는 manager 내부 동작이 독립 수명이나 동시성 경계를 실제로 얻을 때만 진행한다. -- diff/file/commit/ref 로드는 lane별 conflation과 generation guard를 갖춘 `GitLoadWorker`로 비동기화돼 - 있다. 추가 최적화는 측정 결과가 필요할 때 watcher event debouncing이나 lane별 비용을 대상으로 한다. +- 저장소별 상태는 `GitViewManager`와 그 안의 `RepositoryView`로 분리됐다. `App`은 terminal/focus/fullscreen/notice/interaction을 소유하고 명시적 façade로 UI·입력 계층에 저장소 상태를 제공한다. 이후 분리는 manager 내부 동작이 독립 수명이나 동시성 경계를 실제로 얻을 때만 진행한다. +- diff/file/commit/ref 로드는 lane별 conflation과 generation guard를 갖춘 `GitLoadWorker`로 비동기화돼 있다. 추가 최적화는 측정 결과가 필요할 때 watcher event debouncing이나 lane별 비용을 대상으로 한다. ## Detailed design diff --git a/docs/architecture/git-views.md b/docs/architecture/git-views.md index 3ffa367a..116142d0 100644 --- a/docs/architecture/git-views.md +++ b/docs/architecture/git-views.md @@ -1,151 +1,63 @@ # Git Views -상단 패널이 보여주는 세 가지 뷰 — status(변경 파일 + diff), log(커밋 목록 + 드릴다운), -tree(read-only 파일 트리) — 를 떠받치는 데이터 파이프라인과 렌더 규칙을 다룬다. 세 뷰 모두 -같은 `git2::Repository` 캐시와 같은 경로 검증기를 지나며, 우측 pane(diff/file view)은 세 뷰가 -공유한다. - -`GitViewManager`가 저장소 경로·opaque id, repository cache, snapshot/load workers, commit-log -controller, branch/tracking/ref decoration을 한 수명으로 묶는다. 그 안의 `RepositoryView`는 -status/log/tree/diff pane, auto-follow, tree watcher dirty set, snapshot 기반 pending selection을 -소유한다. `App`은 terminal·focus·fullscreen·notice·interaction을 소유한 채 이 manager의 명시적 -façade만 UI와 입력 계층에 제공한다. 따라서 프로젝트 close는 manager를 drop해 worker를 함께 -정리하고, daemon set adopt는 같은 manager에 opaque id만 붙여 선택·watcher·cache를 보존한다. +상단 패널이 보여주는 세 가지 뷰 — status(변경 파일 + diff), log(커밋 목록 + 드릴다운), tree(read-only 파일 트리) — 를 떠받치는 데이터 파이프라인과 렌더 규칙을 다룬다. 세 뷰 모두 같은 `git2::Repository` 캐시와 같은 경로 검증기를 지나며, 우측 pane(diff/file view)은 세 뷰가 공유한다. + +`GitViewManager`가 저장소 경로·opaque id, repository cache, snapshot/load workers, commit-log controller, branch/tracking/ref decoration을 한 수명으로 묶는다. 그 안의 `RepositoryView`는 status/log/tree/diff pane, auto-follow, tree watcher dirty set, snapshot 기반 pending selection을 소유한다. `App`은 terminal·focus·fullscreen·notice·interaction을 소유한 채 이 manager의 명시적 façade만 UI와 입력 계층에 제공한다. 따라서 프로젝트 close는 manager를 drop해 worker를 함께 정리하고, daemon set adopt는 같은 manager에 opaque id만 붙여 선택·watcher·cache를 보존한다. ## Git Diff Pipeline -- **백그라운드 worker 스레드**: `SnapshotChannel`이 `load_snapshot`을 호출해 변경 파일 + - tracking status를 `mpsc` 채널로 푸시한다(읽는 시점 규칙은 - [session.md](session.md#상태는-시간이-아니라-변화에-따라-읽는다-runtimesnapshot_watchrs) 참고). -- **선택 로드 worker**: 파일/커밋 선택, file view, commit drill-down, ref decoration은 - `GitLoadWorker`가 읽고 UI tick은 결과만 적용한다. `git2::Repository`는 `!Send`이므로 worker가 - `Repository::discover`와 cache를 모두 소유한다. 요청은 `(repo, oid/path, generation)`으로 식별하고 - diff/file/commit-files/decorations lane마다 아직 시작하지 않은 요청을 하나로 합친다. 실행 중인 이전 - 요청은 취소할 수 없지만 generation이나 repo가 현재 intent와 다르면 결과를 버리므로 연속 선택, - HEAD 변경, 탭 전환이 과거 내용을 되돌리지 않는다. lane 선택은 round-robin이라 diff 요청이 계속 - 들어와도 file/commit-files/decorations가 굶지 않는다. 프로세스 전체 git I/O와 동일 저장소 I/O에는 - 각각 hard bound가 있고, 종료 제한 안에 끝나지 않은 worker handle도 중앙 registry가 bounded하게 - 추적·회수한다. -- **snapshot reload gate**: 선택 파일의 path·status columns·mtime이 전부 이전 snapshot과 같으면 - 다른 파일이 바뀌었더라도 선택 diff를 다시 읽지 않는다. 선택 파일 자체가 바뀐 in-place refresh만 - 기존 scroll을 유지해 요청하고, 새 선택은 scroll/search cursor를 새 대상에 맞춰 reset한다. -- **경로 검증**: 워크트리 안의 파일·디렉토리를 여는 경로는 전부 `git::path::resolve_in_workdir`를 - 거친다(파일 미리보기와 트리 리스팅 양쪽). plain relative 컴포넌트만 허용하고 - `..`·절대경로·NUL·`.git`(대소문자 무시)을 거부하며, 워크디렉토리부터 한 컴포넌트씩 내려가 - **모든 깊이의 심링크**를 막고 canonicalize containment로 마무리한다. 지금 호출자는 git이 만들어 - 낸 경로만 넘기지만, 검증을 호출부가 아니라 **파일시스템 경계**에 두어야 웹 표면이 요청 문자열을 - 같은 로더에 태워도 안전하다. 크기 검사와 읽기는 같은 파일 핸들에서, 트리 리스팅은 검증기가 - 돌려준 경로로 `read_dir`을 수행해 check→use TOCTOU를 닫는다. `.git` 판정은 `is_git_dir_name` - 하나로 통일한다 — 대소문자와 후행 점·공백(NTFS가 버리는 문자)까지 흡수하며, 규칙을 두 군데에 - 따로 적으면 그 틈이 우회로가 된다. -- **렌더링**: 보이는 행(`scroll_start..scroll_start+visible_height`)에 한해 `syntect`로 syntax - highlighting을 수행한다. 보이지 않는 라인은 highlighter state만 진행시켜 multi-line - construct(블록 주석, 문자열 리터럴)의 연속성을 유지한다. +- **백그라운드 worker 스레드**: `SnapshotChannel`이 `load_snapshot`을 호출해 변경 파일 + tracking status를 `mpsc` 채널로 푸시한다(읽는 시점 규칙은 [session.md](session.md#상태는-시간이-아니라-변화에-따라-읽는다-runtimesnapshot_watchrs) 참고). +- **선택 로드 worker**: 파일/커밋 선택, file view, commit drill-down, ref decoration은 `GitLoadWorker`가 읽고 UI tick은 결과만 적용한다. `git2::Repository`는 `!Send`이므로 worker가 `Repository::discover`와 cache를 모두 소유한다. 요청은 `(repo, oid/path, generation)`으로 식별하고 diff/file/commit-files/decorations lane마다 아직 시작하지 않은 요청을 하나로 합친다. 실행 중인 이전 요청은 취소할 수 없지만 generation이나 repo가 현재 intent와 다르면 결과를 버리므로 연속 선택, HEAD 변경, 탭 전환이 과거 내용을 되돌리지 않는다. lane 선택은 round-robin이라 diff 요청이 계속 들어와도 file/commit-files/decorations가 굶지 않는다. 프로세스 전체 git I/O와 동일 저장소 I/O에는 각각 hard bound가 있고, 종료 제한 안에 끝나지 않은 worker handle도 중앙 registry가 bounded하게 추적·회수한다. +- **snapshot reload gate**: 선택 파일의 path·status columns·mtime이 전부 이전 snapshot과 같으면 다른 파일이 바뀌었더라도 선택 diff를 다시 읽지 않는다. 선택 파일 자체가 바뀐 in-place refresh만 기존 scroll을 유지해 요청하고, 새 선택은 scroll/search cursor를 새 대상에 맞춰 reset한다. +- **경로 검증**: 워크트리 안의 파일·디렉토리를 여는 경로는 전부 `git::path::resolve_in_workdir`를 거친다(파일 미리보기와 트리 리스팅 양쪽). plain relative 컴포넌트만 허용하고 `..`·절대경로·NUL·`.git`(대소문자 무시)을 거부하며, 워크디렉토리부터 한 컴포넌트씩 내려가 **모든 깊이의 심링크**를 막고 canonicalize containment로 마무리한다. 지금 호출자는 git이 만들어 낸 경로만 넘기지만, 검증을 호출부가 아니라 **파일시스템 경계**에 두어야 웹 표면이 요청 문자열을 같은 로더에 태워도 안전하다. 크기 검사와 읽기는 같은 파일 핸들에서, 트리 리스팅은 검증기가 돌려준 경로로 `read_dir`을 수행해 check→use TOCTOU를 닫는다. `.git` 판정은 `is_git_dir_name` 하나로 통일한다 — 대소문자와 후행 점·공백(NTFS가 버리는 문자)까지 흡수하며, 규칙을 두 군데에 따로 적으면 그 틈이 우회로가 된다. +- **렌더링**: 보이는 행(`scroll_start..scroll_start+visible_height`)에 한해 `syntect`로 syntax highlighting을 수행한다. 보이지 않는 라인은 highlighter state만 진행시켜 multi-line construct(블록 주석, 문자열 리터럴)의 연속성을 유지한다. ### 줄 번호 gutter (`ui/diff_viewer/gutter.rs`) -`DiffLine`이 libgit2의 `old_lineno`/`new_lineno`를 그대로 들고 다닌다. 추가 줄은 old가, 삭제 줄은 -new가 `None`이라 해당 칼럼을 비운다 — hunk 헤더에서 파생시키지 않는 이유는 kind별 카운터를 렌더 -층에서 관리하게 되어 상태가 잘못된 층에 놓이기 때문이다. unified은 두 칼럼, split은 좌=old·우=new -한 칼럼씩, file view는 파일 자신의 번호를 보여준다. - -- **gutter와 본문은 반드시 별개 `Paragraph`여야 한다.** diff 계열은 수평 스크롤을 - `Paragraph::scroll((0, x))`로 구현하는데 이건 라인을 통째로 밀기 때문에, 같은 paragraph에 있는 - gutter는 `scroll_x > 0`이면 왼쪽으로 사라진다(실제로 file view에 그 버그가 있었다). `Block`을 - 따로 그리고 `block.inner`를 `Layout::Horizontal`로 쪼개 gutter는 `scroll((0,0))`, 본문만 - 스크롤한다. 수직 스크롤은 **어느 행을 담았는지**로 표현되므로 두 vector를 같은 루프에서 - lockstep으로 채우는 것이 정렬을 지키는 유일한 수단이다. -- 폭은 로드된 hunk 전체의 최대 줄 번호에서 파생하고 최소 3자리(`MIN_LINENO_DIGITS`)를 보장한다. - 보이는 창 기준으로 계산하면 스크롤 중에 본문 좌측 경계가 흔들린다. hunk 헤더 행도 같은 폭의 빈 - gutter를 받아야 `@@`가 본문보다 한 칼럼 왼쪽에서 시작하지 않는다. -- `MIN_SPLIT_WIDTH`를 80 → 90으로 올렸다. 각 half가 gutter에 5칼럼을 쓰므로, 문턱을 그대로 두면 - side-by-side 진입은 되지만 half당 읽을 수 있는 코드 폭이 조용히 줄어든다. +`DiffLine`이 libgit2의 `old_lineno`/`new_lineno`를 그대로 들고 다닌다. 추가 줄은 old가, 삭제 줄은 new가 `None`이라 해당 칼럼을 비운다 — hunk 헤더에서 파생시키지 않는 이유는 kind별 카운터를 렌더 층에서 관리하게 되어 상태가 잘못된 층에 놓이기 때문이다. unified은 두 칼럼, split은 좌=old·우=new 한 칼럼씩, file view는 파일 자신의 번호를 보여준다. + +- **gutter와 본문은 반드시 별개 `Paragraph`여야 한다.** diff 계열은 수평 스크롤을 `Paragraph::scroll((0, x))`로 구현하는데 이건 라인을 통째로 밀기 때문에, 같은 paragraph에 있는 gutter는 `scroll_x > 0`이면 왼쪽으로 사라진다(실제로 file view에 그 버그가 있었다). `Block`을 따로 그리고 `block.inner`를 `Layout::Horizontal`로 쪼개 gutter는 `scroll((0,0))`, 본문만 스크롤한다. 수직 스크롤은 **어느 행을 담았는지**로 표현되므로 두 vector를 같은 루프에서 lockstep으로 채우는 것이 정렬을 지키는 유일한 수단이다. +- 폭은 로드된 hunk 전체의 최대 줄 번호에서 파생하고 최소 3자리(`MIN_LINENO_DIGITS`)를 보장한다. 보이는 창 기준으로 계산하면 스크롤 중에 본문 좌측 경계가 흔들린다. hunk 헤더 행도 같은 폭의 빈 gutter를 받아야 `@@`가 본문보다 한 칼럼 왼쪽에서 시작하지 않는다. +- `MIN_SPLIT_WIDTH`를 80 → 90으로 올렸다. 각 half가 gutter에 5칼럼을 쓰므로, 문턱을 그대로 두면 side-by-side 진입은 되지만 half당 읽을 수 있는 코드 폭이 조용히 줄어든다. ### 자동 줄바꿈 (`DiffPane::wrap`, diff pane focus에서 `w`) -ratatui `Paragraph::wrap`은 켜지면 `scroll.x`를 무시하므로(`render_paragraph`가 wrap 분기에서 -`WordWrapper`만 쓰고 `LineTruncator`의 horizontal offset 경로를 타지 않는다) **줄바꿈과 수평 -스크롤은 구조적으로 배타**다. 켤 때 `scroll_x`를 0으로 되돌린다 — 남겨두면 끌 때 낡은 오프셋이 -되살아난다. - -- 줄바꿈 모드에서는 **gutter를 본문 라인 안으로 접어 넣는다**. 본문 한 줄이 여러 화면 행을 먹는데 - gutter 라인은 한 행이라, 두 paragraph를 나란히 두면 그 아래 전부가 어긋난다. gutter를 분리한 - 애초의 이유(수평 스크롤)가 이 모드엔 없으므로 인라인이 안전하다. 대가는 이어지는 행에 번호가 - 붙지 않는 것. -- **split 뷰는 줄바꿈을 무시한다.** 좌/우 half가 서로 다른 높이로 접히면 행 대응이 무너지는데, 그 - 대응이 이 레이아웃의 유일한 존재 이유다. -- 수직 스크롤은 여전히 **논리 줄** 단위다(렌더러가 창을 직접 슬라이스하고 ratatui의 vertical - scroll을 쓰지 않는다). 따라서 줄바꿈이 켜진 채 긴 줄이 많으면 pane 높이보다 적은 논리 줄만 - 보이고 아래가 잘린다 — 스크롤로 전부 도달할 수 있으므로 감춰지는 내용은 없다. 검색 매치가 논리 - 행 인덱스라는 전제도 이 덕분에 유지된다. +ratatui `Paragraph::wrap`은 켜지면 `scroll.x`를 무시하므로(`render_paragraph`가 wrap 분기에서 `WordWrapper`만 쓰고 `LineTruncator`의 horizontal offset 경로를 타지 않는다) **줄바꿈과 수평 스크롤은 구조적으로 배타**다. 켤 때 `scroll_x`를 0으로 되돌린다 — 남겨두면 끌 때 낡은 오프셋이 되살아난다. + +- 줄바꿈 모드에서는 **gutter를 본문 라인 안으로 접어 넣는다**. 본문 한 줄이 여러 화면 행을 먹는데 gutter 라인은 한 행이라, 두 paragraph를 나란히 두면 그 아래 전부가 어긋난다. gutter를 분리한 애초의 이유(수평 스크롤)가 이 모드엔 없으므로 인라인이 안전하다. 대가는 이어지는 행에 번호가 붙지 않는 것. +- **split 뷰는 줄바꿈을 무시한다.** 좌/우 half가 서로 다른 높이로 접히면 행 대응이 무너지는데, 그 대응이 이 레이아웃의 유일한 존재 이유다. +- 수직 스크롤은 여전히 **논리 줄** 단위다(렌더러가 창을 직접 슬라이스하고 ratatui의 vertical scroll을 쓰지 않는다). 따라서 줄바꿈이 켜진 채 긴 줄이 많으면 pane 높이보다 적은 논리 줄만 보이고 아래가 잘린다 — 스크롤로 전부 도달할 수 있으므로 감춰지는 내용은 없다. 검색 매치가 논리 행 인덱스라는 전제도 이 덕분에 유지된다. ### 표시 방식 전환 -`DiffPaneView`는 `Diff`/`Split`/`File` 세 값인데 `v`(File 토글)와 `s`(Split 토글)는 각각 unified를 -기준으로 한 축만 오간다 — 세 번째가 있다는 걸 모르면 발견할 수 없다. `Tab`(`App::cycle_diff_view`)이 -`Diff → Split → File → Diff`로 셋을 모두 순회해 집합을 드러내고, `v`/`s`는 아는 뷰로 바로 가는 -용도로 남는다. File 단계는 `can_open_file_view`가 거짓이면(선택 없음 / 해석 불가한 커밋 파일) -건너뛴다 — 순회 중 죽은 입력을 만들지 않기 위함이다. Tree 모드는 우측 pane이 항상 파일 -미리보기라 순회 대상이 없어 no-op이다. +`DiffPaneView`는 `Diff`/`Split`/`File` 세 값인데 `v`(File 토글)와 `s`(Split 토글)는 각각 unified를 기준으로 한 축만 오간다 — 세 번째가 있다는 걸 모르면 발견할 수 없다. `Tab`(`App::cycle_diff_view`)이 `Diff → Split → File → Diff`로 셋을 모두 순회해 집합을 드러내고, `v`/`s`는 아는 뷰로 바로 가는 용도로 남는다. File 단계는 `can_open_file_view`가 거짓이면(선택 없음 / 해석 불가한 커밋 파일) 건너뛴다 — 순회 중 죽은 입력을 만들지 않기 위함이다. Tree 모드는 우측 pane이 항상 파일 미리보기라 순회 대상이 없어 no-op이다. ## Status filter cache -`StatusView::filter_cache`는 `search_query` 또는 `files`가 변경될 때만 재계산된다 -(`recompute_filter`). 렌더러와 navigation helper는 캐시된 슬라이스를 읽기만 한다. +`StatusView::filter_cache`는 `search_query` 또는 `files`가 변경될 때만 재계산된다 (`recompute_filter`). 렌더러와 navigation helper는 캐시된 슬라이스를 읽기만 한다. ## File-Tree Navigator (`ViewMode::Tree`) -` b`로 진입하는 read-only 디렉토리 트리. 좌측 리스트가 워크트리 전체를 탐색하고, 파일 -선택은 기존 file-view pane(`DiffPaneView::File`)을 재사용한다 — 새 렌더 경로를 만들지 않는다. - -- **Lazy one-level reads**: `git::tree::read_children`가 `std::fs::read_dir`로 정확히 한 디렉토리 - 레벨만 읽는다. 펼치지 않은 서브트리는 절대 walk되지 않는다. `.gitignore` 필터링은 libgit2를 - 통하고(`[tree] respect_gitignore`), symlink는 non-directory로 보고해 visited-set 없이 순환을 - 차단한다. -- **Derived rows**: `TreeView`는 per-directory child cache와 expanded set만 저장하고, 보이는 행 - 리스트는 `visible_rows`로 매번 파생한다 — 확장 상태와 flatten된 뷰가 어긋날 수 없다. 디렉토리 - I/O는 전부 `app/tree.rs`(UI 스레드 동기)에 있어 populated cache가 주어지면 `tree_view.rs`는 - 순수하고, 파일시스템 없이 단위 테스트된다. -- **파일명 검색**: 트리 focus에서 `/`가 검색 오버레이를 열 때 `build_tree_index`가 `max_depth`까지 - 전체 트리를 한 번 walk해 flat index를 만들고, 이후 필터링은 인메모리다. `Enter`는 선택 경로의 - 조상 디렉토리를 모두 펼쳐 일반 뷰에서 reveal한다. -- **Live watch**: `runtime::tree_watch`가 notify(+debouncer-mini)로 **펼친 디렉토리만 비재귀로** - 감시한다(yazi/broot/nvim-tree와 같은 전략) — 워크트리 전체 재귀 감시는 디렉토리당 inotify watch - 하나를 소비해 대형 트리에서 무너진다. `[tree] live_watch = false`면 Tree 진입 시에만 재조회한다. +` b`로 진입하는 read-only 디렉토리 트리. 좌측 리스트가 워크트리 전체를 탐색하고, 파일 선택은 기존 file-view pane(`DiffPaneView::File`)을 재사용한다 — 새 렌더 경로를 만들지 않는다. + +- **Lazy one-level reads**: `git::tree::read_children`가 `std::fs::read_dir`로 정확히 한 디렉토리 레벨만 읽는다. 펼치지 않은 서브트리는 절대 walk되지 않는다. `.gitignore` 필터링은 libgit2를 통하고(`[tree] respect_gitignore`), symlink는 non-directory로 보고해 visited-set 없이 순환을 차단한다. +- **Derived rows**: `TreeView`는 per-directory child cache와 expanded set만 저장하고, 보이는 행 리스트는 `visible_rows`로 매번 파생한다 — 확장 상태와 flatten된 뷰가 어긋날 수 없다. 디렉토리 I/O는 전부 `app/tree.rs`(UI 스레드 동기)에 있어 populated cache가 주어지면 `tree_view.rs`는 순수하고, 파일시스템 없이 단위 테스트된다. +- **파일명 검색**: 트리 focus에서 `/`가 검색 오버레이를 열 때 `build_tree_index`가 `max_depth`까지 전체 트리를 한 번 walk해 flat index를 만들고, 이후 필터링은 인메모리다. `Enter`는 선택 경로의 조상 디렉토리를 모두 펼쳐 일반 뷰에서 reveal한다. +- **Live watch**: `runtime::tree_watch`가 notify(+debouncer-mini)로 **펼친 디렉토리만 비재귀로** 감시한다(yazi/broot/nvim-tree와 같은 전략) — 워크트리 전체 재귀 감시는 디렉토리당 inotify watch 하나를 소비해 대형 트리에서 무너진다. `[tree] live_watch = false`면 Tree 진입 시에만 재조회한다. - **Read-only 보장**: 트리는 어떤 쓰기·이름변경·삭제도 수행하지 않는다. -- **세션 지속성**: expanded set과 선택 경로는 세션에 저장·복원되며, 복원 시 unsafe 경로와 사라진 - 디렉토리의 stale 확장은 정리된다. +- **세션 지속성**: expanded set과 선택 경로는 세션에 저장·복원되며, 복원 시 unsafe 경로와 사라진 디렉토리의 stale 확장은 정리된다. ## HEAD Change Detection -snapshot worker는 매 폴 사이클마다 현재 HEAD oid를 함께 보고한다. UI 스레드는 `poll_snapshot`에서 -oid 변동을 감지하면 `refresh_commit_log_after_head_change`로 commit log와 drill-down 상태를 동일 -oid 기준으로 재정렬해, 터미널에서 새 커밋·amend·force-push·브랜치 전환이 일어났을 때도 로그 뷰가 -즉시 따라잡는다. +snapshot worker는 매 폴 사이클마다 현재 HEAD oid를 함께 보고한다. UI 스레드는 `poll_snapshot`에서 oid 변동을 감지하면 `refresh_commit_log_after_head_change`로 commit log와 drill-down 상태를 동일 oid 기준으로 재정렬해, 터미널에서 새 커밋·amend·force-push·브랜치 전환이 일어났을 때도 로그 뷰가 즉시 따라잡는다. ## Commit Log Decoration -`git log --decorate`가 주는 방향 감각을 로그 뷰에 옮긴 것이다. `src/git/diff/refs.rs`가 -`repo.references()`를 한 번 걸어 `Oid -> Vec` 맵을 만들고, HEAD·로컬 브랜치·태그·원격 -브랜치를 구분해 커밋 행에 chip으로 그린다. 비용은 커밋 수가 아니라 **ref 수**에 비례하고, -annotated tag은 `peel_to_commit`으로 가리키는 커밋에 붙인다. - -- **재생성 시점은 refs fingerprint가 정한다**: fetch가 `origin/dev`를 옮기면 HEAD는 그대로여도 - chip은 달라져야 한다. snapshot worker가 매 폴마다 ref 이름·타깃의 다이제스트를 - `RepoSnapshot::refs_fingerprint`로 실어 보내고, UI 스레드는 그 값이 바뀔 때만 맵을 다시 만든다. - 재생성 실패는 이전 맵을 유지한다 — 일시적 읽기 오류로 chip이 사라지는 것보다 낫다. -- **ahead/behind는 위치가 아니라 oid 집합으로 판정한다**: 이전 구현은 "위에서 N개가 ahead"라는 - 위치 가정이었고, anchor가 HEAD가 아니거나 필터가 걸리면 마커가 엉뚱한 행에 붙었다. 지금은 - `revwalk.push(local)` + `hide(upstream)`(과 그 반대)로 각 방향의 oid 집합을 만들어 멤버십으로 - 판정한다. 집합은 방향당 `MAX_DIVERGENCE_OIDS`개로 끊는다 — walk가 최신순이므로 잘리는 쪽은 - 화면에 닿지 않는 꼬리다. -- **1 커밋 = 1 행을 유지한다**: `log_view.selected`가 커밋 인덱스이자 화면 위치라는 전제를 - 선택·스크롤·tail prefetch가 공유한다. 여유 공간은 행이 아니라 **컬럼**으로 쓴다. - `area.width >= MIN_DETAIL_WIDTH`이면 상대 시각 대신 절대 시각, author에 email, short_id 10자, - chip 무절단으로 넓힌다. 판정 기준이 `list_fullscreen` 플래그가 아니라 폭인 이유는 넓은 - 모니터에서는 fullscreen이 아니어도 자리가 남기 때문이고, `MIN_SPLIT_WIDTH`가 이미 세운 선례와 - 같은 모양이다. -- **commit graph는 범위 밖이다**: lane graph는 topological 정렬을 전제하는데 현재 revwalk에는 - `set_sorting`이 없고, 정렬을 바꾸면 anchor+skip 페이지네이션 계약까지 함께 다시 설계해야 한다. +`git log --decorate`가 주는 방향 감각을 로그 뷰에 옮긴 것이다. `src/git/diff/refs.rs`가 `repo.references()`를 한 번 걸어 `Oid -> Vec` 맵을 만들고, HEAD·로컬 브랜치·태그·원격 브랜치를 구분해 커밋 행에 chip으로 그린다. 비용은 커밋 수가 아니라 **ref 수**에 비례하고, annotated tag은 `peel_to_commit`으로 가리키는 커밋에 붙인다. + +- **재생성 시점은 refs fingerprint가 정한다**: fetch가 `origin/dev`를 옮기면 HEAD는 그대로여도 chip은 달라져야 한다. snapshot worker가 매 폴마다 ref 이름·타깃의 다이제스트를 `RepoSnapshot::refs_fingerprint`로 실어 보내고, UI 스레드는 그 값이 바뀔 때만 맵을 다시 만든다. 재생성 실패는 이전 맵을 유지한다 — 일시적 읽기 오류로 chip이 사라지는 것보다 낫다. +- **ahead/behind는 위치가 아니라 oid 집합으로 판정한다**: 이전 구현은 "위에서 N개가 ahead"라는 위치 가정이었고, anchor가 HEAD가 아니거나 필터가 걸리면 마커가 엉뚱한 행에 붙었다. 지금은 `revwalk.push(local)` + `hide(upstream)`(과 그 반대)로 각 방향의 oid 집합을 만들어 멤버십으로 판정한다. 집합은 방향당 `MAX_DIVERGENCE_OIDS`개로 끊는다 — walk가 최신순이므로 잘리는 쪽은 화면에 닿지 않는 꼬리다. +- **1 커밋 = 1 행을 유지한다**: `log_view.selected`가 커밋 인덱스이자 화면 위치라는 전제를 선택·스크롤·tail prefetch가 공유한다. 여유 공간은 행이 아니라 **컬럼**으로 쓴다. `area.width >= MIN_DETAIL_WIDTH`이면 상대 시각 대신 절대 시각, author에 email, short_id 10자, chip 무절단으로 넓힌다. 판정 기준이 `list_fullscreen` 플래그가 아니라 폭인 이유는 넓은 모니터에서는 fullscreen이 아니어도 자리가 남기 때문이고, `MIN_SPLIT_WIDTH`가 이미 세운 선례와 같은 모양이다. +- **commit graph는 범위 밖이다**: lane graph는 topological 정렬을 전제하는데 현재 revwalk에는 `set_sorting`이 없고, 정렬을 바꾸면 anchor+skip 페이지네이션 계약까지 함께 다시 설계해야 한다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/plugin-host.md b/docs/architecture/plugin-host.md index 66d39ccd..1e4f120c 100644 --- a/docs/architecture/plugin-host.md +++ b/docs/architecture/plugin-host.md @@ -1,193 +1,54 @@ # Plugin Host -어떤 CLI가 사용량 한도에 걸렸는지 알아보고 한도가 풀린 뒤 세션을 재개하는 일은 provider를 아는 -동작이다. 코어는 그런 ontology를 갖지 않으므로 그 지식을 **별도 프로세스로 분리한다** — 코어 -`src/plugin/`에는 provider를 모르는 host만 두고, Claude Code / Codex / OpenCode를 아는 코드는 -`plugins/nightcrow-recovery`에 산다. 코어 어디에도 그 세 이름은 나오지 않으며, 그것이 이 경계가 -지켜지고 있다는 **검사 가능한 조건**이다. +어떤 CLI가 사용량 한도에 걸렸는지 알아보고 한도가 풀린 뒤 세션을 재개하는 일은 provider를 아는 동작이다. 코어는 그런 ontology를 갖지 않으므로 그 지식을 **별도 프로세스로 분리한다** — 코어 `src/plugin/`에는 provider를 모르는 host만 두고, Claude Code / Codex / OpenCode를 아는 코드는 `plugins/nightcrow-recovery`에 산다. 코어 어디에도 그 세 이름은 나오지 않으며, 그것이 이 경계가 지켜지고 있다는 **검사 가능한 조건**이다. -**이 기능은 provider의 한도를 우회하지 않는다.** 하는 일은 사람이 손으로 하던 것 — 한도가 풀릴 -시각까지 기다렸다가 같은 세션을 다시 여는 것 — 을 대신하는 것뿐이다. 한도를 늘리거나 회피하거나 -감지를 피하는 경로는 없고, 있어서도 안 된다. +**이 기능은 provider의 한도를 우회하지 않는다.** 하는 일은 사람이 손으로 하던 것 — 한도가 풀릴 시각까지 기다렸다가 같은 세션을 다시 여는 것 — 을 대신하는 것뿐이다. 한도를 늘리거나 회피하거나 감지를 피하는 경로는 없고, 있어서도 안 된다. ## 프로세스 경계와 도달 범위 -- **plugin 프로세스는 저장소마다 하나다**: `Plugins::start`는 `TerminalHub::run` 안에 있고 hub은 - 저장소마다 하나이므로(`session/catalog`), 프로젝트가 여섯이면 켜 둔 plugin도 여섯 벌 뜬다. 이것이 - 경계의 형태다 — pane과 마찬가지로 plugin도 저장소 단위로 격리된다. 그 대가로 **plugin은 자신이 - 유일하다고 가정할 수 없다**. host는 hub의 경로에서 runtime 디렉터리를 유도해 - `NIGHTCROW_PLUGIN_RUNTIME_DIR`로 plugin 자식과 그 hub의 pane 양쪽에 심는다 - (`backend::identity::plugin_runtime_dir`). 양쪽이 같은 입력에서 같은 값을 계산하므로 한쪽이 - 다른 쪽에게 알려줄 배관이 없고, pane 안의 helper는 토큰을 읽듯 이 값을 읽어 **자기 pane을 보고 - 있는 인스턴스**의 소켓으로 간다. 경로 대신 고정 폭 digest를 쓰는 이유는 AF_UNIX 경로 상한이 - 107바이트 부근이고 저장소 경로만으로 그 대부분을 쓸 수 있기 때문이다. -- **Windows에서 plugin은 콘솔을 열지 않는다**: 백그라운드 세션은 `DETACHED_PROCESS`로 도므로 물려줄 - 콘솔이 없고, Windows는 그럴 때 콘솔 subsystem 자식에게 **새 콘솔을 할당한다**. plugin마다 창이 하나씩 - 뜨고 그 창을 닫으면 plugin이 죽는다. 자식의 파이프는 전부 host가 열어주므로 콘솔이 필요 없어 - `CREATE_NO_WINDOW`로 막는다(`plugin/host.rs`). +- **plugin 프로세스는 저장소마다 하나다**: `Plugins::start`는 `TerminalHub::run` 안에 있고 hub은 저장소마다 하나이므로(`session/catalog`), 프로젝트가 여섯이면 켜 둔 plugin도 여섯 벌 뜬다. 이것이 경계의 형태다 — pane과 마찬가지로 plugin도 저장소 단위로 격리된다. 그 대가로 **plugin은 자신이 유일하다고 가정할 수 없다**. host는 hub의 경로에서 runtime 디렉터리를 유도해 `NIGHTCROW_PLUGIN_RUNTIME_DIR`로 plugin 자식과 그 hub의 pane 양쪽에 심는다 (`backend::identity::plugin_runtime_dir`). 양쪽이 같은 입력에서 같은 값을 계산하므로 한쪽이 다른 쪽에게 알려줄 배관이 없고, pane 안의 helper는 토큰을 읽듯 이 값을 읽어 **자기 pane을 보고 있는 인스턴스**의 소켓으로 간다. 경로 대신 고정 폭 digest를 쓰는 이유는 AF_UNIX 경로 상한이 107바이트 부근이고 저장소 경로만으로 그 대부분을 쓸 수 있기 때문이다. +- **Windows에서 plugin은 콘솔을 열지 않는다**: 백그라운드 세션은 `DETACHED_PROCESS`로 도므로 물려줄 콘솔이 없고, Windows는 그럴 때 콘솔 subsystem 자식에게 **새 콘솔을 할당한다**. plugin마다 창이 하나씩 뜨고 그 창을 닫으면 plugin이 죽는다. 자식의 파이프는 전부 host가 열어주므로 콘솔이 필요 없어 `CREATE_NO_WINDOW`로 막는다(`plugin/host.rs`). -- **왜 자식 프로세스 + NDJSON인가**: Rust에는 안정 ABI가 없어 `libloading` 기반 dylib plugin은 - 버전이 어긋나는 순간 UB다. cargo feature 게이트는 재컴파일을 요구하므로 "설치·제거 가능"이 - 아니다. 남는 것은 프로세스 경계이고, 그 편이 신뢰 모델도 정직하다 — plugin은 우리 주소 공간에 - 없다. 프레이밍은 stdin/stdout의 개행 구분 JSON이고 버전(`v`)이 맞지 않는 줄은 거부한다. -- **도달 범위의 기본은 opt-in, 확장은 증거로만**: plugin은 `[[startup_command]]`이 - `plugin = "이름"`으로 지목한 pane을 본다. 여기에 `[[plugin]]`의 `watch_on_signal`(기본 `false`)을 - 켜면 두 번째 경로가 열린다 — **pane 자신의 토큰을 제시한 요청**, 즉 - `PluginCommand::WatchPane { token }`이다. 토큰은 spawn 시각에 그 pane의 자식 환경에만 들어가고 - (`pty_spawn.rs`, 명령 없이 연 pane도 예외 없이) 자식들이 상속하므로, 토큰을 말할 수 있는 것은 그 - pane 안에서 도는 프로세스뿐이다. **근거가 열거가 아니라 증명이라는 것이 핵심이다**: plugin에게 - pane 목록을 주는 경로는 여전히 없고, 맨 셸은 어떤 provider helper도 띄우지 않으므로 영원히 - 채택되지 않는다. `[[plugin]]`은 `enabled = false`가 기본이다. -- **왜 그 확장이 필요했나**: 압도적으로 흔한 사용은 ` t`로 셸을 열고 `claude`를 손으로 치는 - 것이다. 그 pane은 `create_pane_with(None, None)`으로 열려 launch command가 없고 `detect(None)`은 - 어떤 provider도 붙이지 못한다 — 그래서 recovery가 **아무것도** 하지 않았다. `WatchPane`은 그 구멍만 - 메운다. `PROTOCOL_VERSION`은 그래서 2가 되었고, 이 명령은 `generation`을 싣지 않는다: 들어본 적 - 없는 pane에 대해 어느 spawn인지 정직하게 주장할 수 없으므로, 답으로 오는 `PaneOpened`가 그것을 - 말한다. `Plugins::start`도 그래서 조건이 둘이다 — enabled이고 **(opt-in됐거나 `watch_on_signal`)**. -- **요청은 plugin 쪽에서 먼저 줄인다**(`runloop_adopt.rs`): 거부는 응답이 없는 것과 구별되지 않으므로 - 답을 못 받은 요청이 타이트 루프가 되거나 낯선 토큰마다 상태를 남기면 안 된다. 미해결 요청은 - `MAX_PENDING`개까지만 들고(초과분은 새 것을 버려 실패를 닫힌 방향으로 낸다), 같은 토큰은 - `REQUEST_COOLDOWN` 동안 다시 묻지 않는다 — Claude Code의 statusline은 매 렌더마다 돌기 때문에, 이게 - 없으면 남의 pane 하나가 host의 tick당 예산을 정작 필요한 요청과 함께 태운다. 그리고 요청을 정당화한 - **신호는 버리지 않고 들고 있다가 `PaneOpened` 뒤에 재생한다**: 신호가 pane보다 먼저 도착하고 host는 - 새로 넘긴 pane에 어떤 history도 재생해 주지 않으므로, 버리면 지금 복구해야 할 그 한도가 사라진다. - 이때 provider는 명령줄이 아니라 `detect_from_signal`이 고른다 — `SignalKind`는 정확히 한 adapter의 - helper만 발행하므로 신호 종류 자체가 증거이고, 그래서 두 번째 sniffing 경로가 아니라 wire kind에 - 대한 lookup이다. -- **늦게 채택된 pane은 relaunch되지 않는다**: launch command가 `None`이므로 프로세스를 되돌려 놓으면 - provider가 아니라 셸이 다시 뜬다. guard는 이것을 `Refused::NoLaunchCommand`로 — 인자 문제와 - 구별되는 자기 이유로 — 거부하고, `allowed_resume_flags`를 어떻게 열어도 통과하지 않는다. hub도 같은 - 판단을 한다: watched pane이 종료했을 때 `is_relaunchable`이 거짓이면 `PENDING_RELAUNCH_TTL` 동안 - slot을 붙잡는 대신 곧바로 닫는다. 이런 pane이 받을 수 있는 recovery는 살아 있는 프로세스에 타이핑하는 - 것 하나뿐이고, plugin 쪽도 같은 결론을 미리 내려 `NeedsAttention`으로 간다(`state_resume.rs`). +- **왜 자식 프로세스 + NDJSON인가**: Rust에는 안정 ABI가 없어 `libloading` 기반 dylib plugin은 버전이 어긋나는 순간 UB다. cargo feature 게이트는 재컴파일을 요구하므로 "설치·제거 가능"이 아니다. 남는 것은 프로세스 경계이고, 그 편이 신뢰 모델도 정직하다 — plugin은 우리 주소 공간에 없다. 프레이밍은 stdin/stdout의 개행 구분 JSON이고 버전(`v`)이 맞지 않는 줄은 거부한다. +- **도달 범위의 기본은 opt-in, 확장은 증거로만**: plugin은 `[[startup_command]]`이 `plugin = "이름"`으로 지목한 pane을 본다. 여기에 `[[plugin]]`의 `watch_on_signal`(기본 `false`)을 켜면 두 번째 경로가 열린다 — **pane 자신의 토큰을 제시한 요청**, 즉 `PluginCommand::WatchPane { token }`이다. 토큰은 spawn 시각에 그 pane의 자식 환경에만 들어가고 (`pty_spawn.rs`, 명령 없이 연 pane도 예외 없이) 자식들이 상속하므로, 토큰을 말할 수 있는 것은 그 pane 안에서 도는 프로세스뿐이다. **근거가 열거가 아니라 증명이라는 것이 핵심이다**: plugin에게 pane 목록을 주는 경로는 여전히 없고, 맨 셸은 어떤 provider helper도 띄우지 않으므로 영원히 채택되지 않는다. `[[plugin]]`은 `enabled = false`가 기본이다. +- **왜 그 확장이 필요했나**: 압도적으로 흔한 사용은 ` t`로 셸을 열고 `claude`를 손으로 치는 것이다. 그 pane은 `create_pane_with(None, None)`으로 열려 launch command가 없고 `detect(None)`은 어떤 provider도 붙이지 못한다 — 그래서 recovery가 **아무것도** 하지 않았다. `WatchPane`은 그 구멍만 메운다. `PROTOCOL_VERSION`은 그래서 2가 되었고, 이 명령은 `generation`을 싣지 않는다: 들어본 적 없는 pane에 대해 어느 spawn인지 정직하게 주장할 수 없으므로, 답으로 오는 `PaneOpened`가 그것을 말한다. `Plugins::start`도 그래서 조건이 둘이다 — enabled이고 **(opt-in됐거나 `watch_on_signal`)**. +- **요청은 plugin 쪽에서 먼저 줄인다**(`runloop_adopt.rs`): 거부는 응답이 없는 것과 구별되지 않으므로 답을 못 받은 요청이 타이트 루프가 되거나 낯선 토큰마다 상태를 남기면 안 된다. 미해결 요청은 `MAX_PENDING`개까지만 들고(초과분은 새 것을 버려 실패를 닫힌 방향으로 낸다), 같은 토큰은 `REQUEST_COOLDOWN` 동안 다시 묻지 않는다 — Claude Code의 statusline은 매 렌더마다 돌기 때문에, 이게 없으면 남의 pane 하나가 host의 tick당 예산을 정작 필요한 요청과 함께 태운다. 그리고 요청을 정당화한 **신호는 버리지 않고 들고 있다가 `PaneOpened` 뒤에 재생한다**: 신호가 pane보다 먼저 도착하고 host는 새로 넘긴 pane에 어떤 history도 재생해 주지 않으므로, 버리면 지금 복구해야 할 그 한도가 사라진다. 이때 provider는 명령줄이 아니라 `detect_from_signal`이 고른다 — `SignalKind`는 정확히 한 adapter의 helper만 발행하므로 신호 종류 자체가 증거이고, 그래서 두 번째 sniffing 경로가 아니라 wire kind에 대한 lookup이다. +- **늦게 채택된 pane은 relaunch되지 않는다**: launch command가 `None`이므로 프로세스를 되돌려 놓으면 provider가 아니라 셸이 다시 뜬다. guard는 이것을 `Refused::NoLaunchCommand`로 — 인자 문제와 구별되는 자기 이유로 — 거부하고, `allowed_resume_flags`를 어떻게 열어도 통과하지 않는다. hub도 같은 판단을 한다: watched pane이 종료했을 때 `is_relaunchable`이 거짓이면 `PENDING_RELAUNCH_TTL` 동안 slot을 붙잡는 대신 곧바로 닫는다. 이런 pane이 받을 수 있는 recovery는 살아 있는 프로세스에 타이핑하는 것 하나뿐이고, plugin 쪽도 같은 결론을 미리 내려 `NeedsAttention`으로 간다(`state_resume.rs`). ## 신뢰 경계 (`guard.rs`) -`protocol::decode_command`는 모양과 크기만 본다. 권한은 `Guard::judge`만 판단하고 plugin이 우회할 -경로가 없다. 규칙: pane이 존재하고 opt-in했는가, `generation`이 현재와 같은가(이것이 교체된 -프로세스에 대한 결정이 후임에게 닿는 것을 막는다), 살아 있고 조용할 때만 입력을 넣는가, 죽었을 때만 -relaunch하는가, 되돌릴 명령이 있는가, 제어문자가 섞이지 않았는가, slot당 횟수 상한 안인가. 거부는 -로그로 남고 재시도되지 않는다. +`protocol::decode_command`는 모양과 크기만 본다. 권한은 `Guard::judge`만 판단하고 plugin이 우회할 경로가 없다. 규칙: pane이 존재하고 opt-in했는가, `generation`이 현재와 같은가(이것이 교체된 프로세스에 대한 결정이 후임에게 닿는 것을 막는다), 살아 있고 조용할 때만 입력을 넣는가, 죽었을 때만 relaunch하는가, 되돌릴 명령이 있는가, 제어문자가 섞이지 않았는가, slot당 횟수 상한 안인가. 거부는 로그로 남고 재시도되지 않는다. -- **pane을 얻는 규칙만 따로 산다**(`guard_watch.rs`): 나머지 규칙이 모두 "이미 배정된 pane"에서 - 출발하는 데 반해 이것은 배정 자체를 만드는 유일한 자리라, 큰 판단 안의 분기가 아니라 조건 목록 - 하나로 읽히게 분리했다. 순서대로 — 토큰이 아는 pane인가, `watch_on_signal`이 켜졌는가, 다른 - plugin이 이미 보고 있지 않은가(pane 하나에 watcher 하나. 둘이 같은 키보드를 몰면 서로가 바꾸는 - 상태 위에서 recovery가 섞인다), 프로세스가 살아 있는가. **예산은 청구하지 않는다** — pane을 받는 - 것은 pane에 하는 일이 아니고, 이어질 행위는 각각 청구된다. 이미 자기 것인 pane을 다시 물으면 - **거부가 아니라 승인**이다: 명령줄로는 안에 있는 것을 알아볼 수 없었던 opt-in pane이 다시 시도할 - 유일한 방법이 `PaneOpened`를 한 번 더 받는 것이기 때문이다. 알 수 없는 토큰이 압도적 다수라는 것도 - 이 설계의 전제다 — 같은 사용자의 다른 nightcrow 세션 pane들이 같은 소켓에 닿는다. -- **`PaneToken`이 정체성인 이유**: `PaneId`는 backend별 카운터라 backend가 다시 만들어지면 1로 - 돌아간다. cwd도 답이 못 된다 — 한 저장소에 여러 pane을 두는 것이 지원되는 레이아웃이다. 그래서 - 난수 토큰을 spawn 시각에 자식 환경(`NIGHTCROW_PANE_TOKEN`)으로 넣는다. provider가 띄우는 - hook/statusline 자식들이 이를 상속하므로 plugin은 어떤 pane에서 온 사건인지 추측 없이 안다. -- **횟수 상한은 slot(토큰) 기준으로 센다**: relaunch는 반드시 새 `PaneId`를 만든다. 상한을 id로 세면 - relaunch마다 예산이 새로 생겨, 즉시 끝나는 명령과 매 종료마다 relaunch하는 plugin이 만나면 상한에 - 영원히 닿지 않는다. 토큰은 relaunch를 건너 살아남는 유일한 값이다. -- **relaunch는 같은 id를 되살리지 않는다**: id는 단조 증가하고 모든 클라이언트가 `Exited`를 그 id의 - 종결로 취급한다. 교체는 새 id로 태어나되 토큰을 물려받고 generation이 오른다. 레이아웃은 새 pane을 - 원래 인덱스에 넣고 기존 `Reordered`를 브로드캐스트해 보존한다 — 와이어 포맷에 relaunch 전용 - 메시지를 추가하지 않는다. -- **프로세스 해제와 slot 폐기를 분리한다**: 한도 대기는 몇 시간일 수 있다. 죽은 자식의 fd와 스레드를 - 그 시간 내내 붙잡는 것은 낭비이므로 `release_process`는 PTY를 놓고 slot만 남긴다. 아무도 - relaunch하지 않으면 `PENDING_RELAUNCH_TTL`에 slot을 폐기한다. -- **권한 인자는 사용자가 선언한다**: relaunch의 첫 토큰(플래그 또는 subcommand)과 `-`/`/`로 - 시작하는 option 토큰은 `[[plugin]].allowed_resume_flags`에 있어야 하며 기본은 빈 목록이다. 코어가 - 특정 CLI의 위험 플래그 이름을 하드코딩하면 provider 경계를 깨므로 택하지 않았다. 허용 문자는 POSIX - shell과 `cmd.exe`에서 그대로 전달되는 안전한 공통 집합으로 제한하며 별도 quote 문자를 넣지 않는다. - 원래 명령 문자열은 수정하지 않아 다음 relaunch에 인자가 누적되지 않는다. -- **와이어 계약이 두 벌 있다**: plugin은 독립 빌드라 `plugins/nightcrow-recovery`가 프로토콜 타입을 - 따로 갖는다. `PROTOCOL_VERSION`을 진짜 주장으로 만들려면 그래야 하고, 양쪽 모두 JSON 모양을 - 리터럴로 고정한 테스트가 있어 드리프트는 테스트 실패로 나타난다. +- **pane을 얻는 규칙만 따로 산다**(`guard_watch.rs`): 나머지 규칙이 모두 "이미 배정된 pane"에서 출발하는 데 반해 이것은 배정 자체를 만드는 유일한 자리라, 큰 판단 안의 분기가 아니라 조건 목록 하나로 읽히게 분리했다. 순서대로 — 토큰이 아는 pane인가, `watch_on_signal`이 켜졌는가, 다른 plugin이 이미 보고 있지 않은가(pane 하나에 watcher 하나. 둘이 같은 키보드를 몰면 서로가 바꾸는 상태 위에서 recovery가 섞인다), 프로세스가 살아 있는가. **예산은 청구하지 않는다** — pane을 받는 것은 pane에 하는 일이 아니고, 이어질 행위는 각각 청구된다. 이미 자기 것인 pane을 다시 물으면 **거부가 아니라 승인**이다: 명령줄로는 안에 있는 것을 알아볼 수 없었던 opt-in pane이 다시 시도할 유일한 방법이 `PaneOpened`를 한 번 더 받는 것이기 때문이다. 알 수 없는 토큰이 압도적 다수라는 것도 이 설계의 전제다 — 같은 사용자의 다른 nightcrow 세션 pane들이 같은 소켓에 닿는다. +- **`PaneToken`이 정체성인 이유**: `PaneId`는 backend별 카운터라 backend가 다시 만들어지면 1로 돌아간다. cwd도 답이 못 된다 — 한 저장소에 여러 pane을 두는 것이 지원되는 레이아웃이다. 그래서 난수 토큰을 spawn 시각에 자식 환경(`NIGHTCROW_PANE_TOKEN`)으로 넣는다. provider가 띄우는 hook/statusline 자식들이 이를 상속하므로 plugin은 어떤 pane에서 온 사건인지 추측 없이 안다. +- **횟수 상한은 slot(토큰) 기준으로 센다**: relaunch는 반드시 새 `PaneId`를 만든다. 상한을 id로 세면 relaunch마다 예산이 새로 생겨, 즉시 끝나는 명령과 매 종료마다 relaunch하는 plugin이 만나면 상한에 영원히 닿지 않는다. 토큰은 relaunch를 건너 살아남는 유일한 값이다. +- **relaunch는 같은 id를 되살리지 않는다**: id는 단조 증가하고 모든 클라이언트가 `Exited`를 그 id의 종결로 취급한다. 교체는 새 id로 태어나되 토큰을 물려받고 generation이 오른다. 레이아웃은 새 pane을 원래 인덱스에 넣고 기존 `Reordered`를 브로드캐스트해 보존한다 — 와이어 포맷에 relaunch 전용 메시지를 추가하지 않는다. +- **프로세스 해제와 slot 폐기를 분리한다**: 한도 대기는 몇 시간일 수 있다. 죽은 자식의 fd와 스레드를 그 시간 내내 붙잡는 것은 낭비이므로 `release_process`는 PTY를 놓고 slot만 남긴다. 아무도 relaunch하지 않으면 `PENDING_RELAUNCH_TTL`에 slot을 폐기한다. +- **권한 인자는 사용자가 선언한다**: relaunch의 첫 토큰(플래그 또는 subcommand)과 `-`/`/`로 시작하는 option 토큰은 `[[plugin]].allowed_resume_flags`에 있어야 하며 기본은 빈 목록이다. 코어가 특정 CLI의 위험 플래그 이름을 하드코딩하면 provider 경계를 깨므로 택하지 않았다. 허용 문자는 POSIX shell과 `cmd.exe`에서 그대로 전달되는 안전한 공통 집합으로 제한하며 별도 quote 문자를 넣지 않는다. 원래 명령 문자열은 수정하지 않아 다음 relaunch에 인자가 누적되지 않는다. +- **와이어 계약이 두 벌 있다**: plugin은 독립 빌드라 `plugins/nightcrow-recovery`가 프로토콜 타입을 따로 갖는다. `PROTOCOL_VERSION`을 진짜 주장으로 만들려면 그래야 하고, 양쪽 모두 JSON 모양을 리터럴로 고정한 테스트가 있어 드리프트는 테스트 실패로 나타난다. ## provider 쪽 (`plugins/nightcrow-recovery`) -- **provider의 설정 파일은 병합만 한다**(`hooks.rs` / `hooks_merge.rs`): `~/.claude/settings.json`은 - 사용자 것이고 우리가 모르는 키를 담고 있을 수 있으므로, 모든 수정은 우리가 넣지 않은 것을 보존하는 - 병합이고, 파일을 이해할 수 없으면(JSON이 아니거나 top-level이 object가 아니면) 추측하는 대신 멈춘다. - 쓰기는 같은 디렉터리의 temp file → rename이고 모드 `0600`은 rename **전에** 건다, 첫 쓰기 전에 - `.bak`을 남긴다. 등록하는 hook event는 정확히 하나다 — `HOOK_EVENT = "StopFailure"`, - `HOOK_MATCHER = "rate_limit"` 아래 `{"type":"command","command":" hook","timeout":5}`. 최소 - 권한이라서 그렇다: `authentication_failed`·`billing_error` 같은 무관한 실패의 payload는 이 프로세스에 - 아예 도달하지 않고, 그 대가로 일시적 `overloaded`/`server_error`는 pane 출력에서 알아본다. 우리 - 엔트리를 알아보는 표시는 `command` 문자열에 `MARKER`가 들어 있는지 하나뿐이다 — provider의 - 스키마에서 자유 텍스트를 넣을 수 있는 필드가 거기뿐이다. 그래서 install은 `current_exe()`로 해석한 - 절대 경로가 `MARKER`를 담지 않으면 **거부한다**(나중에 uninstall이 자기 엔트리를 못 알아본다). - 경로를 `argv[0]`이 아니라 해석해서 쓰는 이유는 그 파일을 읽는 것이 작업 디렉터리가 다른 프로세스라는 - 것이다. -- **helper는 provider의 임계 경로에 있으므로 최소한만 한다**(`helper.rs`): 등록되는 명령은 이 plugin의 - 바이너리를 내부 서브커맨드로 다시 부르는 것이다(`Mode::Hook` / `Mode::Statusline`). `hook()`은 - stdin을 상한까지만 읽고 `["session_id","error_type","hook_event_name"]`만 통과시킨다 — - **whitelisting이 프라이버시 경계다**. `StopFailure` payload는 transcript 파일 경로와 provider의 에러 - 산문을 담으므로, 상태 기계가 실제로 읽는 필드만 소켓을 건넌다. 어느 실패도 호출자에게 보고하지 - 않는다 — 돌지 않는 recovery plugin은 설치되지 않은 것과 정확히 같아 보여야 한다. -- **IPC 랑데부는 경로 규칙 하나다**(`ipc.rs`): `$XDG_RUNTIME_DIR/nightcrow/recovery.sock`, 없으면 - `~/.nightcrow/run/recovery.sock`. 디렉터리는 `0700`, 소켓은 `0600`이고 bind마다 다시 건다. 남아 있는 - 소켓 파일은 **아무도 듣고 있지 않을 때만** unlink한다. `parse_line`은 줄 크기, JSON object 여부, `v` - 일치, 토큰의 문자 집합과 길이, 아는 `kind`, object payload를 모두 검사하고 실패마다 무엇이 틀렸는지 - 말한다 — 여기가 untrusted input이 상태가 되는 경계이므로 조용히 강제 변환하는 필드가 곧 버그다. - **토큰은 correlation key이고 authorisation이 아니다**: 위조된 메시지가 할 수 있는 최대는 이 plugin이 - host에게 무언가를 묻게 만드는 것이며 그것은 guard가 처음부터 다시 판단한다. -- **statusline은 가로채지 않고 이어붙인다**(`helper_statusline.rs` / `helper_delegate.rs`): - `statusLine`은 목록이 아니라 명령 하나라 install은 사용자 것을 반드시 밀어낸다. 지금은 - `helper::statusline()`이 pass-through다 — stdin 바이트를 **그대로** 보관하고, 사본만 파싱해 - `rate_limits`를 IPC로 넘기고, sidecar에 기록해 둔 밀려난 명령을 그 원본 바이트를 stdin으로 주어 - 실행한 뒤 그 stdout을 출력한다. 재직렬화하지 않는 이유는 키 순서와 숫자 표기가 provider의 것이기 - 때문이다. 실행은 `sh -c`로 한다 — Claude Code가 `statusLine` 명령은 셸에서 돈다고 문서화하고 자기 - 예시가 `~`, `jq` 파이프, 인라인 `$(...)`에 의존한다. `$SHELL`이 아니라 `sh`인 것은 대화형 셸이면 - refresh마다 rc 파일을 읽기 때문이다. 예산은 2초이고 넘기면 죽이고 우리 줄로 떨어진다 — 이 상한은 - 끝나지 않는 명령이 이 프로세스를 불멸로 만들지 않게 하기 위한 것이다. stderr는 버린다. sidecar에 든 - 것이 우리 자신의 바이너리면 다시 실행하지 않는다(`is_ours` 재사용). 모든 실패 경로는 plugin 자신의 - 줄로 격하된다 — 에러를 띄우는 statusline은 평범한 statusline보다 나쁘다. **비자명한 함정 하나**: - 밀어낼 `statusLine`이 애초에 없었으면 `merge_into`가 `Some(Value::Null)`을 돌려주므로 **sidecar가 - `null`을 담을 수 있다**. 없음만이 빈 경우가 아니고, `null`도 "실행할 것이 없다"로 읽어야 한다. -- **관측 부담을 지지 않는 쪽으로**: 출력 텍스트는 chunk 단위로 escape를 벗겨 넘기므로 두 read에 걸친 - escape는 완전히 제거되지 않는다. 허용되는 이유는 출력 텍스트가 언제나 fallback 신호일 뿐이라는 - 것이다 — Claude는 hook과 statusline, Codex는 rollout JSONL, OpenCode는 로컬 서버의 세션 상태가 1차 - 신호다. -- **신호의 역할은 분리돼 있고, 이것이 하중을 받는 사실이다**(`provider/claude.rs`): 한도를 **선언**할 - 수 있는 것은 `StopFailure`(`on_stop_failure`)와 출력 fallback뿐이다. statusline은 정확한 reset - epoch만 공급하고 결코 선언하지 않는다 — `on_rate_limits`는 `resets_at`만 기억하고 - `used_percentage`는 100이어도 의도적으로 무시한다. 여러 창이 보고되면 가장 이른 것이 유용한 - deadline이다. 이 분리의 결과가 `state_clock.rs`의 `arm_wait`에서 갈린다: `LimitKind::UsageLimit`이고 - `resets_at`이 알려져 있으면 `WaitingForReset`으로 **정확히 한 번** 기다리고 resume attempt를 쓰지 - 않는다. 모르면 `arm_backoff`로 떨어지고, 그쪽은 attempt 예산에 묶인 재시도 루프라 - `MAX_RESUME_ATTEMPTS`에 닿으면 `NeedsAttention`으로 끝난다. 그래서 hook과 statusline을 둘 다 - 설치하는 것의 실질적 이득은 "감지"가 아니라 **기다림이 정확해지고 예산을 쓰지 않는다**는 것이다. -- **OpenCode에는 개입하지 않는다**: 자체 재시도가 상한 없이 계속되므로 "재시도 소진"을 기다리는 설계가 - 성립하지 않는다. 프로세스가 끝났거나 상태가 `idle`로 바뀐 뒤에만 손을 댄다. +- **provider의 설정 파일은 병합만 한다**(`hooks.rs` / `hooks_merge.rs`): `~/.claude/settings.json`은 사용자 것이고 우리가 모르는 키를 담고 있을 수 있으므로, 모든 수정은 우리가 넣지 않은 것을 보존하는 병합이고, 파일을 이해할 수 없으면(JSON이 아니거나 top-level이 object가 아니면) 추측하는 대신 멈춘다. 쓰기는 같은 디렉터리의 temp file → rename이고 모드 `0600`은 rename **전에** 건다, 첫 쓰기 전에 `.bak`을 남긴다. 등록하는 hook event는 정확히 하나다 — `HOOK_EVENT = "StopFailure"`, `HOOK_MATCHER = "rate_limit"` 아래 `{"type":"command","command":" hook","timeout":5}`. 최소 권한이라서 그렇다: `authentication_failed`·`billing_error` 같은 무관한 실패의 payload는 이 프로세스에 아예 도달하지 않고, 그 대가로 일시적 `overloaded`/`server_error`는 pane 출력에서 알아본다. 우리 엔트리를 알아보는 표시는 `command` 문자열에 `MARKER`가 들어 있는지 하나뿐이다 — provider의 스키마에서 자유 텍스트를 넣을 수 있는 필드가 거기뿐이다. 그래서 install은 `current_exe()`로 해석한 절대 경로가 `MARKER`를 담지 않으면 **거부한다**(나중에 uninstall이 자기 엔트리를 못 알아본다). 경로를 `argv[0]`이 아니라 해석해서 쓰는 이유는 그 파일을 읽는 것이 작업 디렉터리가 다른 프로세스라는 것이다. +- **helper는 provider의 임계 경로에 있으므로 최소한만 한다**(`helper.rs`): 등록되는 명령은 이 plugin의 바이너리를 내부 서브커맨드로 다시 부르는 것이다(`Mode::Hook` / `Mode::Statusline`). `hook()`은 stdin을 상한까지만 읽고 `["session_id","error_type","hook_event_name"]`만 통과시킨다 — **whitelisting이 프라이버시 경계다**. `StopFailure` payload는 transcript 파일 경로와 provider의 에러 산문을 담으므로, 상태 기계가 실제로 읽는 필드만 소켓을 건넌다. 어느 실패도 호출자에게 보고하지 않는다 — 돌지 않는 recovery plugin은 설치되지 않은 것과 정확히 같아 보여야 한다. +- **IPC 랑데부는 경로 규칙 하나다**(`ipc.rs`): `$XDG_RUNTIME_DIR/nightcrow/recovery.sock`, 없으면 `~/.nightcrow/run/recovery.sock`. 디렉터리는 `0700`, 소켓은 `0600`이고 bind마다 다시 건다. 남아 있는 소켓 파일은 **아무도 듣고 있지 않을 때만** unlink한다. `parse_line`은 줄 크기, JSON object 여부, `v` 일치, 토큰의 문자 집합과 길이, 아는 `kind`, object payload를 모두 검사하고 실패마다 무엇이 틀렸는지 말한다 — 여기가 untrusted input이 상태가 되는 경계이므로 조용히 강제 변환하는 필드가 곧 버그다. **토큰은 correlation key이고 authorisation이 아니다**: 위조된 메시지가 할 수 있는 최대는 이 plugin이 host에게 무언가를 묻게 만드는 것이며 그것은 guard가 처음부터 다시 판단한다. +- **statusline은 가로채지 않고 이어붙인다**(`helper_statusline.rs` / `helper_delegate.rs`): `statusLine`은 목록이 아니라 명령 하나라 install은 사용자 것을 반드시 밀어낸다. 지금은 `helper::statusline()`이 pass-through다 — stdin 바이트를 **그대로** 보관하고, 사본만 파싱해 `rate_limits`를 IPC로 넘기고, sidecar에 기록해 둔 밀려난 명령을 그 원본 바이트를 stdin으로 주어 실행한 뒤 그 stdout을 출력한다. 재직렬화하지 않는 이유는 키 순서와 숫자 표기가 provider의 것이기 때문이다. 실행은 `sh -c`로 한다 — Claude Code가 `statusLine` 명령은 셸에서 돈다고 문서화하고 자기 예시가 `~`, `jq` 파이프, 인라인 `$(...)`에 의존한다. `$SHELL`이 아니라 `sh`인 것은 대화형 셸이면 refresh마다 rc 파일을 읽기 때문이다. 예산은 2초이고 넘기면 죽이고 우리 줄로 떨어진다 — 이 상한은 끝나지 않는 명령이 이 프로세스를 불멸로 만들지 않게 하기 위한 것이다. stderr는 버린다. sidecar에 든 것이 우리 자신의 바이너리면 다시 실행하지 않는다(`is_ours` 재사용). 모든 실패 경로는 plugin 자신의 줄로 격하된다 — 에러를 띄우는 statusline은 평범한 statusline보다 나쁘다. **비자명한 함정 하나**: 밀어낼 `statusLine`이 애초에 없었으면 `merge_into`가 `Some(Value::Null)`을 돌려주므로 **sidecar가 `null`을 담을 수 있다**. 없음만이 빈 경우가 아니고, `null`도 "실행할 것이 없다"로 읽어야 한다. +- **관측 부담을 지지 않는 쪽으로**: 출력 텍스트는 chunk 단위로 escape를 벗겨 넘기므로 두 read에 걸친 escape는 완전히 제거되지 않는다. 허용되는 이유는 출력 텍스트가 언제나 fallback 신호일 뿐이라는 것이다 — Claude는 hook과 statusline, Codex는 rollout JSONL, OpenCode는 로컬 서버의 세션 상태가 1차 신호다. +- **신호의 역할은 분리돼 있고, 이것이 하중을 받는 사실이다**(`provider/claude.rs`): 한도를 **선언**할 수 있는 것은 `StopFailure`(`on_stop_failure`)와 출력 fallback뿐이다. statusline은 정확한 reset epoch만 공급하고 결코 선언하지 않는다 — `on_rate_limits`는 `resets_at`만 기억하고 `used_percentage`는 100이어도 의도적으로 무시한다. 여러 창이 보고되면 가장 이른 것이 유용한 deadline이다. 이 분리의 결과가 `state_clock.rs`의 `arm_wait`에서 갈린다: `LimitKind::UsageLimit`이고 `resets_at`이 알려져 있으면 `WaitingForReset`으로 **정확히 한 번** 기다리고 resume attempt를 쓰지 않는다. 모르면 `arm_backoff`로 떨어지고, 그쪽은 attempt 예산에 묶인 재시도 루프라 `MAX_RESUME_ATTEMPTS`에 닿으면 `NeedsAttention`으로 끝난다. 그래서 hook과 statusline을 둘 다 설치하는 것의 실질적 이득은 "감지"가 아니라 **기다림이 정확해지고 예산을 쓰지 않는다**는 것이다. +- **OpenCode에는 개입하지 않는다**: 자체 재시도가 상한 없이 계속되므로 "재시도 소진"을 기다리는 설계가 성립하지 않는다. 프로세스가 끝났거나 상태가 `idle`로 바뀐 뒤에만 손을 댄다. ## Recovery Surface (사람이 보고 취소하는 쪽) -plugin의 `status` 보고는 `ServerMessage::Recovery { pane, state, detail?, deadline_epoch?, attempt }`로 -모든 클라이언트에 브로드캐스트되고, 사람은 `ClientMessage::CancelRecovery { pane }`로 되돌려 준다. - -- **hub는 보고를 보관하지 않는다**: 도착한 그대로 브로드캐스트하고 잊는다. hub가 소유하는 것은 - hold(exited pane의 slot)뿐이고 사람이 빼앗을 수 있는 것도 그것뿐이다. 따라서 표시 상태는 클라이언트가 - 최신 보고를 들고 있는 것으로 성립한다. -- **`state`는 해석하지 않는다**: plugin이 고른 짧은 문자열이며 코어는 뜻을 모른다. 유일한 예외가 hub - 자신이 보내는 `"cancelled"`(`hub_recovery::RECOVERY_CANCELLED`)이고, 클라이언트는 이것을 "이 pane에 - 더는 대기 중인 것이 없다"로 읽어 엔트리를 **지운다**. -- **hold가 끝나는 모든 경로가 `cancelled`를 보낸다**: 취소, TTL 만료, relaunch 성공, 명시적 close. - 하나라도 빠지면 클라이언트에 지나간 deadline이 영구히 남는다. -- **취소는 hold를 근거로 판정한다**: `claim_pending`이 비면 아무 일도 하지 않는다(에러가 아니다 — - 클라이언트는 만료보다 한 박자 늦을 수 있다). hold가 있으면 `pane_closed` → `Plugins::forget` → - `retire_slot` 순서다. `forget`이 slot의 토큰으로 예산을 지우므로 `retire_slot`보다 앞이어야 한다. -- **TUI는 행을 추가하지 않는다**: 표시는 (1) pane 탭 라벨의 짧은 마커(`⏳17:45` / `⚠3`, - `ui/terminal_tab/recovery.rs`)와 (2) notice row 마지막 칩(`ui/notice.rs`)뿐이다. 전용 행이나 - 오버레이를 만들지 않은 이유는 Layout·Notice Row와 같다 — 행이 생겼다 사라지면 열려 있는 모든 PTY가 - 리사이즈된다. 좁은 pane에서는 제목이 먼저 잘리고 마커가 남는다(`RECOVERY_TITLE_MAX_CHARS`). -- **취소 키는 leader 뒤에 있다**: ` c`. bare 키는 pane 안 프로그램의 것이라는 Keyboard Routing - 규칙 그대로이며, 대기 중인 것이 있을 때만 힌트에 노출된다. -- **탭이 없는 pane도 가리킬 수 있어야 한다**: 프로세스가 끝나고 slot만 남은 pane은 클라이언트의 pane - 목록에 없다. 그래서 표시·취소 대상은 "focus된 pane의 보고, 없으면 목록에 없는 pane의 보고(가장 낮은 - id)"로 정의된다(`TerminalState::recovery_focus`, 웹은 `lib/recovery.ts::orphanRecovery`). 웹에서는 - 그런 보고가 pane 셀 대신 패널 툴바에 뜬다. -- **deadline은 절대 추측하지 않는다**: `deadline_epoch`가 없으면 시각을 아무것도 그리지 않는다. 틀린 - 벽시계 시각은 사실처럼 읽힌다. TUI는 날짜 크레이트 없이 `libc::localtime_r`로 `HH:MM`만 만들고 - (`ui/wall_clock.rs`), unix가 아닌 플랫폼에서는 UTC로 떨어진다. -- **터미널 렌더링과 결합하지 않는다**: 화면 내용이 아니라 pane 메타데이터이므로 emulator/xterm 경로에 - 닿지 않는다. TUI는 `TerminalState.recovery` 맵, 웹은 컨트롤 프레임에서 파생된 상태다. +plugin의 `status` 보고는 `ServerMessage::Recovery { pane, state, detail?, deadline_epoch?, attempt }`로 모든 클라이언트에 브로드캐스트되고, 사람은 `ClientMessage::CancelRecovery { pane }`로 되돌려 준다. + +- **hub는 보고를 보관하지 않는다**: 도착한 그대로 브로드캐스트하고 잊는다. hub가 소유하는 것은 hold(exited pane의 slot)뿐이고 사람이 빼앗을 수 있는 것도 그것뿐이다. 따라서 표시 상태는 클라이언트가 최신 보고를 들고 있는 것으로 성립한다. +- **`state`는 해석하지 않는다**: plugin이 고른 짧은 문자열이며 코어는 뜻을 모른다. 유일한 예외가 hub 자신이 보내는 `"cancelled"`(`hub_recovery::RECOVERY_CANCELLED`)이고, 클라이언트는 이것을 "이 pane에 더는 대기 중인 것이 없다"로 읽어 엔트리를 **지운다**. +- **hold가 끝나는 모든 경로가 `cancelled`를 보낸다**: 취소, TTL 만료, relaunch 성공, 명시적 close. 하나라도 빠지면 클라이언트에 지나간 deadline이 영구히 남는다. +- **취소는 hold를 근거로 판정한다**: `claim_pending`이 비면 아무 일도 하지 않는다(에러가 아니다 — 클라이언트는 만료보다 한 박자 늦을 수 있다). hold가 있으면 `pane_closed` → `Plugins::forget` → `retire_slot` 순서다. `forget`이 slot의 토큰으로 예산을 지우므로 `retire_slot`보다 앞이어야 한다. +- **TUI는 행을 추가하지 않는다**: 표시는 (1) pane 탭 라벨의 짧은 마커(`⏳17:45` / `⚠3`, `ui/terminal_tab/recovery.rs`)와 (2) notice row 마지막 칩(`ui/notice.rs`)뿐이다. 전용 행이나 오버레이를 만들지 않은 이유는 Layout·Notice Row와 같다 — 행이 생겼다 사라지면 열려 있는 모든 PTY가 리사이즈된다. 좁은 pane에서는 제목이 먼저 잘리고 마커가 남는다(`RECOVERY_TITLE_MAX_CHARS`). +- **취소 키는 leader 뒤에 있다**: ` c`. bare 키는 pane 안 프로그램의 것이라는 Keyboard Routing 규칙 그대로이며, 대기 중인 것이 있을 때만 힌트에 노출된다. +- **탭이 없는 pane도 가리킬 수 있어야 한다**: 프로세스가 끝나고 slot만 남은 pane은 클라이언트의 pane 목록에 없다. 그래서 표시·취소 대상은 "focus된 pane의 보고, 없으면 목록에 없는 pane의 보고(가장 낮은 id)"로 정의된다(`TerminalState::recovery_focus`, 웹은 `lib/recovery.ts::orphanRecovery`). 웹에서는 그런 보고가 pane 셀 대신 패널 툴바에 뜬다. +- **deadline은 절대 추측하지 않는다**: `deadline_epoch`가 없으면 시각을 아무것도 그리지 않는다. 틀린 벽시계 시각은 사실처럼 읽힌다. TUI는 날짜 크레이트 없이 `libc::localtime_r`로 `HH:MM`만 만들고 (`ui/wall_clock.rs`), unix가 아닌 플랫폼에서는 UTC로 떨어진다. +- **터미널 렌더링과 결합하지 않는다**: 화면 내용이 아니라 pane 메타데이터이므로 emulator/xterm 경로에 닿지 않는다. TUI는 `TerminalState.recovery` 맵, 웹은 컨트롤 프레임에서 파생된 상태다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 272c4c46..49331709 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -1,9 +1,6 @@ # Session & Backend -세션 데몬이 소유하는 것과 클라이언트가 각자 갖는 것의 경계, 그 경계를 표현하는 -`TerminalBackend` trait, 살아 있는 세션에 설정을 다시 읽히는 경로, 그리고 백그라운드 -worker의 종료 정책을 다룬다. 이 문서의 결정은 대부분 "표면이 여럿"이라는 하나의 사실에서 -파생된다 — 한 세션에 attach한 TUI와 브라우저가 동시에 붙어 있을 수 있다. +세션 데몬이 소유하는 것과 클라이언트가 각자 갖는 것의 경계, 그 경계를 표현하는 `TerminalBackend` trait, 살아 있는 세션에 설정을 다시 읽히는 경로, 그리고 백그라운드 worker의 종료 정책을 다룬다. 이 문서의 결정은 대부분 "표면이 여럿"이라는 하나의 사실에서 파생된다 — 한 세션에 attach한 TUI와 브라우저가 동시에 붙어 있을 수 있다. ## TerminalBackend Trait @@ -22,456 +19,140 @@ trait TerminalBackend { } ``` -- `PtyBackend`(`backend/pty.rs`): portable-pty로 PTY를 만들고 reader 스레드가 출력·Exited를 - 채널로 푸시한다. 터미널 허브가 **구체 타입으로 소유**하며 `open_pane`으로 id를 직답받는다 — - 만든 즉시 등록해야 하기 때문이다. -- `HubBackend`(`backend/hub.rs`): 데몬 소켓 위에 얹은 같은 trait. 저장소당 하나이고 attach - 연결을 공유한다. 아무것도 소유하지 않고 요청한다. +- `PtyBackend`(`backend/pty.rs`): portable-pty로 PTY를 만들고 reader 스레드가 출력·Exited를 채널로 푸시한다. 터미널 허브가 **구체 타입으로 소유**하며 `open_pane`으로 id를 직답받는다 — 만든 즉시 등록해야 하기 때문이다. +- `HubBackend`(`backend/hub.rs`): 데몬 소켓 위에 얹은 같은 trait. 저장소당 하나이고 attach 연결을 공유한다. 아무것도 소유하지 않고 요청한다. **소유하지 않는다는 사실이 trait을 네 군데 바꿨다.** -1. **pane은 반환값이 아니라 이벤트로 온다.** id는 PTY가 실제로 사는 곳에서 나오고, 남이 연 - pane도 같은 경로로 와야 한다. `create_pane`은 "요청"이고 `BackendEvent::Created`가 도착을 - 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 - 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. -2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 "PTY 크기" 참고). `Resized`를 - 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. 로컬 `PtyBackend`는 성공 시 - `Applied`, 원격 `HubBackend`는 서버 확인이 남았다는 `Pending`을 반환한다. 호출 실패는 - `Result`로 전파되며 적용 성공처럼 에뮬레이터나 세션 상태에 기록하지 않는다. -3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 - 투영하는 서버 canonical order다. -4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 - PTY에서 온 것과 똑같이 먹는다. 뷰어에서 xterm.js가 서 있는 자리와 같다. - -- **Pane 생명주기 단일 owner**: `drain_events`는 보고만 하고 제거하지 않는다. `Exited`를 받은 - 쪽이 `destroy_pane`을 호출해 PTY를 놓는다 — 클라이언트에서는 `TerminalState::poll`, 허브에서는 - 워커 루프다. 허브가 그것을 빼먹어 스스로 끝난 pane의 master fd가 샜다(캡은 live pane만 세므로 - 열고 끝내기를 반복하면 무한히 쌓인다). -- **닫기와 순서도 요청이다.** `close_active`는 pane을 그 자리에서 지우지 않고 `Exited`를 - 기다린다 — 세션이 실행하지 않은 닫기(커맨드 큐가 꽉 찬 경우)가 있으면 프로세스는 살아 있는데 - 이 클라이언트만 그 pane을 영영 못 보게 된다. 남의 클라이언트가 닫은 pane이 오는 경로와 같다. -- **세션이 시작 터미널의 이름을 준다.** `[[startup_command]] name`(없으면 커맨드 텍스트)이 - `Created`에 실려 모든 클라이언트가 같은 이름을 쓴다. 클라이언트가 직접 연 pane은 이름 없이 - 오고, 어느 쪽이든 프로그램의 OSC 0/2가 나중에 덮어쓴다 — 그 덮어쓰기도 세션이 기억하므로 - 나중에 붙는 클라이언트가 같은 이름을 본다(아래 "붙는 클라이언트에게는 기록이 아니라 상태를 - 준다" 참고). +1. **pane은 반환값이 아니라 이벤트로 온다.** id는 PTY가 실제로 사는 곳에서 나오고, 남이 연 pane도 같은 경로로 와야 한다. `create_pane`은 "요청"이고 `BackendEvent::Created`가 도착을 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. +2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 "PTY 크기" 참고). `Resized`를 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. 로컬 `PtyBackend`는 성공 시 `Applied`, 원격 `HubBackend`는 서버 확인이 남았다는 `Pending`을 반환한다. 호출 실패는 `Result`로 전파되며 적용 성공처럼 에뮬레이터나 세션 상태에 기록하지 않는다. +3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 투영하는 서버 canonical order다. +4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 PTY에서 온 것과 똑같이 먹는다. 뷰어에서 xterm.js가 서 있는 자리와 같다. + +- **Pane 생명주기 단일 owner**: `drain_events`는 보고만 하고 제거하지 않는다. `Exited`를 받은 쪽이 `destroy_pane`을 호출해 PTY를 놓는다 — 클라이언트에서는 `TerminalState::poll`, 허브에서는 워커 루프다. 허브가 그것을 빼먹어 스스로 끝난 pane의 master fd가 샜다(캡은 live pane만 세므로 열고 끝내기를 반복하면 무한히 쌓인다). +- **닫기와 순서도 요청이다.** `close_active`는 pane을 그 자리에서 지우지 않고 `Exited`를 기다린다 — 세션이 실행하지 않은 닫기(커맨드 큐가 꽉 찬 경우)가 있으면 프로세스는 살아 있는데 이 클라이언트만 그 pane을 영영 못 보게 된다. 남의 클라이언트가 닫은 pane이 오는 경로와 같다. +- **세션이 시작 터미널의 이름을 준다.** `[[startup_command]] name`(없으면 커맨드 텍스트)이 `Created`에 실려 모든 클라이언트가 같은 이름을 쓴다. 클라이언트가 직접 연 pane은 이름 없이 오고, 어느 쪽이든 프로그램의 OSC 0/2가 나중에 덮어쓴다 — 그 덮어쓰기도 세션이 기억하므로 나중에 붙는 클라이언트가 같은 이름을 본다(아래 "붙는 클라이언트에게는 기록이 아니라 상태를 준다" 참고). ## 세션 공유 (데몬 ↔ 클라이언트) -무엇이 공유이고 무엇이 클라이언트별인지가 이 앱의 중심 결정이다. 전부 공유하면 브라우저에서 -커서를 내릴 때 TUI 커서도 내려가 "디스플레이별 렌더링"이 의미를 잃고, 전부 로컬이면 같은 세션에 -붙은 두 화면이 서로 다른 것을 보여준다. - -- **공유(데몬 소유)**: 저장소 집합과 순서, **활성 프로젝트**, 터미널 pane 집합·내용·순서·크기, - 그리고 **accent** -- **뷰어 안에서만 공유(브라우저 간, TUI와는 공유 안 함)**: 사이드바 폭(`sidebar_width`), 터미널 - 패널 높이(`upper_pct`), 그리고 **프로젝트별 마지막 뷰**(`views` — 탭, 열려 있던 파일, 트리 - 펼침). 모두 `viewer.json`에 살지만 attach한 TUI는 읽지 않는다 — 폭은 TUI에 대응 값이 없어서, - 높이는 대응 값(`config.layout.upper_pct`)이 있어도 공유가 틀린 답이어서, 마지막 뷰는 TUI가 - **같은 것을 자기 파일에 이미 들고 있고 그 파일의 주인이 TUI라서**다([web.md](web.md)). +무엇이 공유이고 무엇이 클라이언트별인지가 이 앱의 중심 결정이다. 전부 공유하면 브라우저에서 커서를 내릴 때 TUI 커서도 내려가 "디스플레이별 렌더링"이 의미를 잃고, 전부 로컬이면 같은 세션에 붙은 두 화면이 서로 다른 것을 보여준다. + +- **공유(데몬 소유)**: 저장소 집합과 순서, **활성 프로젝트**, 터미널 pane 집합·내용·순서·크기, 그리고 **accent** +- **뷰어 안에서만 공유(브라우저 간, TUI와는 공유 안 함)**: 사이드바 폭(`sidebar_width`), 터미널 패널 높이(`upper_pct`), 그리고 **프로젝트별 마지막 뷰**(`views` — 탭, 열려 있던 파일, 트리 펼침). 모두 `viewer.json`에 살지만 attach한 TUI는 읽지 않는다 — 폭은 TUI에 대응 값이 없어서, 높이는 대응 값(`config.layout.upper_pct`)이 있어도 공유가 틀린 답이어서, 마지막 뷰는 TUI가 **같은 것을 자기 파일에 이미 들고 있고 그 파일의 주인이 TUI라서**다([web.md](web.md)). - **클라이언트별**: 커서·스크롤 위치, 포커스, fullscreen, 검색 텍스트 -**accent는 원래 클라이언트별이었다.** 뒤집은 이유는 한 세션에 표면이 여럿이라는 사실이 그 -편의보다 무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느 -쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느 -프로젝트인가"는 탭 이름과 활성 탭 강조가 이미 답한다. 값은 `viewer.json` 하나에 살고 -(`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 -그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. +**accent는 원래 클라이언트별이었다.** 뒤집은 이유는 한 세션에 표면이 여럿이라는 사실이 그 편의보다 무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느 쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느 프로젝트인가"는 탭 이름과 활성 탭 강조가 이미 답한다. 값은 `viewer.json` 하나에 살고 (`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. ### Catalog는 membership과 runtime을 분리한다 (`session/catalog/`) -저장소 집합을 결정하는 순수 상태(`CatalogMembership`)와 실제 status worker·terminal hub를 -소유하는 상태(`CatalogRuntime`)는 별개다. membership은 base·browser-added·hidden·order의 합집합과 -재사용하지 않는 id만 계산하고, runtime은 그 결과를 reconcile해 같은 path의 `Arc`를 -그대로 보존한다. 따라서 무관한 탭 변경은 기존 runtime과 SSE subscriber를 교체하지 않는다. +저장소 집합을 결정하는 순수 상태(`CatalogMembership`)와 실제 status worker·terminal hub를 소유하는 상태(`CatalogRuntime`)는 별개다. membership은 base·browser-added·hidden·order의 합집합과 재사용하지 않는 id만 계산하고, runtime은 그 결과를 reconcile해 같은 path의 `Arc`를 그대로 보존한다. 따라서 무관한 탭 변경은 기존 runtime과 SSE subscriber를 교체하지 않는다. -둘 사이 변경은 `Catalog` façade의 transaction 하나로 직렬화한다. config table 교체도 같은 -transaction을 써서 동시에 열린 저장소는 교체 시점의 fan-out에 포함되거나 새 table로 spawn되는 -둘 중 하나이며, 어느 쪽에도 속하지 않는 틈이 없다. reconcile은 먼저 새 runtime snapshot을 -설치하고 retired entry를 값으로 돌려준다. worker `stop`과 join은 membership·runtime·transaction -lock을 모두 놓은 뒤 실행한다 — 닫히는 저장소 하나의 종료 지연이 조회나 다음 catalog mutation을 -막지 않게 하기 위해서다. +둘 사이 변경은 `Catalog` façade의 transaction 하나로 직렬화한다. config table 교체도 같은 transaction을 써서 동시에 열린 저장소는 교체 시점의 fan-out에 포함되거나 새 table로 spawn되는 둘 중 하나이며, 어느 쪽에도 속하지 않는 틈이 없다. reconcile은 먼저 새 runtime snapshot을 설치하고 retired entry를 값으로 돌려준다. worker `stop`과 join은 membership·runtime·transaction lock을 모두 놓은 뒤 실행한다 — 닫히는 저장소 하나의 종료 지연이 조회나 다음 catalog mutation을 막지 않게 하기 위해서다. ### 데몬이 세션을 감시한다 (`daemon/watch.rs`) -세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는 -attach 소켓의 아무것도 깨우지 않는다. watcher 스레드가 틱마다 세션을 다시 읽어 마지막으로 알린 -것과 다르면 브로드캐스트한다. **알림(callback)이 아니라 관측인 이유**: 알림은 나중에 추가된 -mutation이 빼먹을 수 있고, 그 실패가 정확히 "브라우저 변경이 TUI에 안 닿는" 버그로 다시 -나타난다. 그래서 브로드캐스트하는 곳이 하나이고, 새로 생긴 저장소의 터미널을 모든 클라이언트에 -구독시키는 것도 여기다 — 소켓을 읽는 스레드는 `read`에 막혀 있어 할 수 없다. attach -클라이언트의 요청은 watcher를 **즉시 깨우므로**(`Nudge`) 키 입력이 폴링 간격을 기다리지 않는다. - -**세트를 보내는 곳도 watcher 하나다.** 붙는 클라이언트도, 세트를 직접 물어본(`ListRepos`) -클라이언트도 자기가 보내지 않고 "아직 못 받았다"고 등록만 하고 watcher를 깨운다 -(`clients.rs`의 `owed_set`). 한 큐에 생산자가 하나면 **프레임 순서가 곧 상태가 바뀐 순서**이기 -때문이다. 전에는 attach 스레드와 watcher가 각자 보냈고, 둘 사이에 변경이 끼면 갓 붙은 -클라이언트가 다른 모두가 떠난 상태에 남았다(watcher는 이미 "모두에게 알렸다"고 기록했다). -순서를 락으로 맞추는 대신 경쟁을 없애는 쪽이며, 뷰어의 preference 쓰기(`serialWrite.ts`)와 탭 -순서 변경이 이미 같은 결론에 도달해 있다. 그래서 watcher를 띄우지 못하면 데몬은 **시작하지 -않는다**(`serve::start`). +세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는 attach 소켓의 아무것도 깨우지 않는다. watcher 스레드가 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 브로드캐스트한다. **알림(callback)이 아니라 관측인 이유**: 알림은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 "브라우저 변경이 TUI에 안 닿는" 버그로 다시 나타난다. 그래서 브로드캐스트하는 곳이 하나이고, 새로 생긴 저장소의 터미널을 모든 클라이언트에 구독시키는 것도 여기다 — 소켓을 읽는 스레드는 `read`에 막혀 있어 할 수 없다. attach 클라이언트의 요청은 watcher를 **즉시 깨우므로**(`Nudge`) 키 입력이 폴링 간격을 기다리지 않는다. + +**세트를 보내는 곳도 watcher 하나다.** 붙는 클라이언트도, 세트를 직접 물어본(`ListRepos`) 클라이언트도 자기가 보내지 않고 "아직 못 받았다"고 등록만 하고 watcher를 깨운다 (`clients.rs`의 `owed_set`). 한 큐에 생산자가 하나면 **프레임 순서가 곧 상태가 바뀐 순서**이기 때문이다. 전에는 attach 스레드와 watcher가 각자 보냈고, 둘 사이에 변경이 끼면 갓 붙은 클라이언트가 다른 모두가 떠난 상태에 남았다(watcher는 이미 "모두에게 알렸다"고 기록했다). 순서를 락으로 맞추는 대신 경쟁을 없애는 쪽이며, 뷰어의 preference 쓰기(`serialWrite.ts`)와 탭 순서 변경이 이미 같은 결론에 도달해 있다. 그래서 watcher를 띄우지 못하면 데몬은 **시작하지 않는다**(`serve::start`). ### PTY 크기는 한 클라이언트가 정한다 -PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이다 — 자식은 들은 폭에 맞춰 그리고, -alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 없다. 그래서 tmux의 -`window-size latest`와 같은 모델을 쓴다: **뷰어의 도착이 곧 소유권 이전**, 이미 붙어 있으면 -`claim_size`로 명시적 탈취(TUI ` z`, 뷰어의 "fit to this screen" 버튼), 소유자가 떠나면 -남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지. - -- **소유권은 hub별이 아니라 세션 하나가 갖는다**(`session/size_owner.rs`). 어느 repo가 앞에 - 있는지는 세션 공유라 "이 세션은 어느 화면에 맞춰져 있나"는 질문이 하나다. hub마다 따로 - 답하던 때는 탭을 옮길 때마다 붙어 있는 모든 페이지가 동시에 재접속해 소유권이 **핸드셰이크가 - 늦게 끝난 쪽**으로 갔다. -- **뷰어는 커넥션이 아니다.** `접속 = 소유자 도착`은 소켓이 열렸다는 사실에서 의도를 읽어내는 - 것인데, 소켓은 사람이 앉는 것 말고도 열린다: repo 전환, 새로고침, 네트워크 끊김. 그래서 뷰어는 - 자기 이름을 대고(`ViewerId` — 브라우저는 탭당 id, attach한 TUI는 데몬 client id 하나로 모든 - repo 구독을 묶는다) **방금 도착했는지를 직접 말한다**. 브라우저는 `sessionStorage`에 탭당 id를 - 두고(`lib/viewerId.ts`) `/ws/term`에 `viewer=`로 실어 보내며, 페이지가 처음 뜨는 한 번만 - `claim=1`을 붙인다. `localStorage`가 아닌 이유는 그것이 탭별이 아니어서 한 브라우저의 두 탭이 - 한 뷰어가 되기 때문이다. `viewer=`가 없거나 형식이 어긋나면 서버가 일회용 id를 발급한다 — - 거부가 아니라 이름을 대기 전의 동작으로 강등된다. -- **해제에는 유예가 있다**(`RELEASE_GRACE`, 2초). repo를 옮기면 소켓 하나가 닫히고 다른 하나가 - 열리는데, 그 사이의 공백은 부재가 아니다. 유예를 끝내는 것은 hub worker의 tick(`settle`)이다 — - 볼 사람이 있으려면 hub가 돌고 있어야 하므로 전용 타이머가 필요 없다. -- **주인 없음은 빈 세션의 상태다.** 아무도 없을 때만 소유자가 없고, 뷰어가 하나라도 있으면 그중 - 하나가 갖는다. 그래서 주인 없는 상태에 커넥션이 붙으면 도착이 아니어도 그것이 가져간다 — - 밀려날 사람이 없으니 "재접속은 남의 화면을 뺏지 않는다"는 조심성이 지킬 것이 없다. 이것이 - 없을 때 폰은 깰 때마다 관전자로 돌아왔다: 잠들면 페이지가 얼어 소켓이 죽고, 유예가 지나 - 소유권이 풀리고, 재접속은 도착이 아니므로 아무도 그것을 집지 않았다. 그 상태의 페이지는 - 떠난 화면의 크기로 pane을 그리고 fit 버튼을 띄운다. -- **소유권이 움직이면 로그가 남는다**(`size_owner_audit.rs`, INFO). 뷰어의 접속·해제 - (`viewer connection joined` / `left`)와 소유권 이전(`terminal sizing moved`)을 이유와 함께 - 남긴다 — `reason`은 `a viewer arrived`, `a viewer asked`, `nobody owned it`, - `the owner stayed gone` 넷 중 하나다. 이 전이는 붙어 있는 모든 클라이언트의 렌더를 바꾸는데, - 클라이언트가 볼 수 없는 이유로도 일어난다(마지막 커넥션이 끊김, worker tick에서 유예 만료). - 기록이 없으면 나중에 읽을 것이 증상뿐이다. -- 비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 - 자식이 감는 곳에서 감아야 하기 때문이다. 소유자는 `desired`(현재 레이아웃), `pending`(마지막 - 전송과 시각), `confirmed`(`Resized`로 확인한 실제 크기)를 분리한다. 늦은 이전 ACK가 에뮬레이터를 - 과거 폭으로 돌려도 `desired != confirmed`가 남아 최종 폭을 다시 요청하며, ACK가 오지 않으면 - 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. -- **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded - queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 - 별도 보관한다. worker는 일반 command를 64개 처리할 때마다 이를 합성 처리해 지속적인 입력에도 - resize가 굶지 않으며, 연결이 끝나면 그 connection의 보류 값을 제거해 재접속 churn에도 저장량을 - 붙은 connection·pane 수 안에 묶는다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 적용을 - 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 갱신하거나 - `Resized`를 브로드캐스트하지 않는다. -- 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 - repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 - 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. +PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이다 — 자식은 들은 폭에 맞춰 그리고, alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 없다. 그래서 tmux의 `window-size latest`와 같은 모델을 쓴다: **뷰어의 도착이 곧 소유권 이전**, 이미 붙어 있으면 `claim_size`로 명시적 탈취(TUI ` z`, 뷰어의 "fit to this screen" 버튼), 소유자가 떠나면 남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지. + +- **소유권은 hub별이 아니라 세션 하나가 갖는다**(`session/size_owner.rs`). 어느 repo가 앞에 있는지는 세션 공유라 "이 세션은 어느 화면에 맞춰져 있나"는 질문이 하나다. hub마다 따로 답하던 때는 탭을 옮길 때마다 붙어 있는 모든 페이지가 동시에 재접속해 소유권이 **핸드셰이크가 늦게 끝난 쪽**으로 갔다. +- **뷰어는 커넥션이 아니다.** `접속 = 소유자 도착`은 소켓이 열렸다는 사실에서 의도를 읽어내는 것인데, 소켓은 사람이 앉는 것 말고도 열린다: repo 전환, 새로고침, 네트워크 끊김. 그래서 뷰어는 자기 이름을 대고(`ViewerId` — 브라우저는 탭당 id, attach한 TUI는 데몬 client id 하나로 모든 repo 구독을 묶는다) **방금 도착했는지를 직접 말한다**. 브라우저는 `sessionStorage`에 탭당 id를 두고(`lib/viewerId.ts`) `/ws/term`에 `viewer=`로 실어 보내며, 페이지가 처음 뜨는 한 번만 `claim=1`을 붙인다. `localStorage`가 아닌 이유는 그것이 탭별이 아니어서 한 브라우저의 두 탭이 한 뷰어가 되기 때문이다. `viewer=`가 없거나 형식이 어긋나면 서버가 일회용 id를 발급한다 — 거부가 아니라 이름을 대기 전의 동작으로 강등된다. +- **해제에는 유예가 있다**(`RELEASE_GRACE`, 2초). repo를 옮기면 소켓 하나가 닫히고 다른 하나가 열리는데, 그 사이의 공백은 부재가 아니다. 유예를 끝내는 것은 hub worker의 tick(`settle`)이다 — 볼 사람이 있으려면 hub가 돌고 있어야 하므로 전용 타이머가 필요 없다. +- **주인 없음은 빈 세션의 상태다.** 아무도 없을 때만 소유자가 없고, 뷰어가 하나라도 있으면 그중 하나가 갖는다. 그래서 주인 없는 상태에 커넥션이 붙으면 도착이 아니어도 그것이 가져간다 — 밀려날 사람이 없으니 "재접속은 남의 화면을 뺏지 않는다"는 조심성이 지킬 것이 없다. 이것이 없을 때 폰은 깰 때마다 관전자로 돌아왔다: 잠들면 페이지가 얼어 소켓이 죽고, 유예가 지나 소유권이 풀리고, 재접속은 도착이 아니므로 아무도 그것을 집지 않았다. 그 상태의 페이지는 떠난 화면의 크기로 pane을 그리고 fit 버튼을 띄운다. +- **소유권이 움직이면 로그가 남는다**(`size_owner_audit.rs`, INFO). 뷰어의 접속·해제 (`viewer connection joined` / `left`)와 소유권 이전(`terminal sizing moved`)을 이유와 함께 남긴다 — `reason`은 `a viewer arrived`, `a viewer asked`, `nobody owned it`, `the owner stayed gone` 넷 중 하나다. 이 전이는 붙어 있는 모든 클라이언트의 렌더를 바꾸는데, 클라이언트가 볼 수 없는 이유로도 일어난다(마지막 커넥션이 끊김, worker tick에서 유예 만료). 기록이 없으면 나중에 읽을 것이 증상뿐이다. +- 비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 자식이 감는 곳에서 감아야 하기 때문이다. 소유자는 `desired`(현재 레이아웃), `pending`(마지막 전송과 시각), `confirmed`(`Resized`로 확인한 실제 크기)를 분리한다. 늦은 이전 ACK가 에뮬레이터를 과거 폭으로 돌려도 `desired != confirmed`가 남아 최종 폭을 다시 요청하며, ACK가 오지 않으면 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. +- **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 별도 보관한다. worker는 일반 command를 64개 처리할 때마다 이를 합성 처리해 지속적인 입력에도 resize가 굶지 않으며, 연결이 끝나면 그 connection의 보류 값을 제거해 재접속 churn에도 저장량을 붙은 connection·pane 수 안에 묶는다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 적용을 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 갱신하거나 `Resized`를 브로드캐스트하지 않는다. +- 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. ### attach 터미널 수신은 유계 FIFO다 -attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 저장소의 에뮬레이터는 TUI 렌더 틱에서 -자기 inbox를 소비한다(`daemon/terminal_link.rs`). 소켓 reader가 렌더보다 빠르다는 이유로 메모리를 -무제한 늘리거나, 반대로 최신 출력만 남기는 것은 둘 다 허용되지 않는다. 터미널 바이트는 snapshot이 -아니라 순서 있는 스트림이므로 중간 한 바이트를 버리면 뒤의 모든 escape sequence 해석이 틀어진다. - -- **drain은 저장소별 FIFO prefix만 꺼낸다.** 한 틱에 최대 64개 메시지와 256 KiB를 처리한다. 첫 - 메시지가 256 KiB보다 큰 replay frame이면 그것 하나는 꺼내야 head가 영원히 막히지 않는다. 예산 - 뒤의 메시지는 다음 틱에 남고, 모든 열린 저장소가 틱마다 각자 drain하므로 시끄러운 저장소 하나가 - 다른 탭의 입력·출력과 화면 갱신을 굶기지 않는다. -- **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 - 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 - 건너뛰면 마지막 출력이 사라진다. -- **연결 전체에 256 MiB output + 4,096 message 상한을 둔다.** output allowance는 데몬 쪽 연결 - 큐가 합법적으로 보낼 수 있는 256개의 1 MiB replay frame과 같은 크기이고, 별도 message 상한은 - control event-only 폭주도 저장량을 무제한 키우지 못하게 한다. 저장소를 닫거나 drain하면 그 몫을 - 즉시 돌려준다. - 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 - 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 - screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. +attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 저장소의 에뮬레이터는 TUI 렌더 틱에서 자기 inbox를 소비한다(`daemon/terminal_link.rs`). 소켓 reader가 렌더보다 빠르다는 이유로 메모리를 무제한 늘리거나, 반대로 최신 출력만 남기는 것은 둘 다 허용되지 않는다. 터미널 바이트는 snapshot이 아니라 순서 있는 스트림이므로 중간 한 바이트를 버리면 뒤의 모든 escape sequence 해석이 틀어진다. + +- **drain은 저장소별 FIFO prefix만 꺼낸다.** 한 틱에 최대 64개 메시지와 256 KiB를 처리한다. 첫 메시지가 256 KiB보다 큰 replay frame이면 그것 하나는 꺼내야 head가 영원히 막히지 않는다. 예산 뒤의 메시지는 다음 틱에 남고, 모든 열린 저장소가 틱마다 각자 drain하므로 시끄러운 저장소 하나가 다른 탭의 입력·출력과 화면 갱신을 굶기지 않는다. +- **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 건너뛰면 마지막 출력이 사라진다. +- **연결 전체에 256 MiB output + 4,096 message 상한을 둔다.** output allowance는 데몬 쪽 연결 큐가 합법적으로 보낼 수 있는 256개의 1 MiB replay frame과 같은 크기이고, 별도 message 상한은 control event-only 폭주도 저장량을 무제한 키우지 못하게 한다. 저장소를 닫거나 drain하면 그 몫을 즉시 돌려준다. 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. ### 상태는 시간이 아니라 변화에 따라 읽는다 (`runtime/snapshot_watch.rs`) -`git status` 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 -**129 ms**다. 1초마다 돌리면 아무 일도 없는 시간에도 그만큼을 태운다. 그래서 워크트리를 -**재귀 감시**하고 변화가 있을 때만 읽는다. 옆의 트리 워처가 재귀 감시를 거부한 것과 다른 -결론인데, 트리 뷰는 펼친 디렉토리만 필요해서 재귀가 낭비지만 status는 트리 전체가 대상이라 더 -작은 감시 집합이 없다. 남는 위험(리눅스 inotify 디스크립터 소진)은 **설치 실패 시 예전의 1초 -폴링으로 폴백**해서 받는다. 이 폴백은 **끈적하다** — 실패 원인(watch 상한, 권한)은 1초 뒤에 -달라지지 않으므로, 재시도는 아무도 안 보던 저장소를 다시 볼 때만 일어난다. +`git status` 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 **129 ms**다. 1초마다 돌리면 아무 일도 없는 시간에도 그만큼을 태운다. 그래서 워크트리를 **재귀 감시**하고 변화가 있을 때만 읽는다. 옆의 트리 워처가 재귀 감시를 거부한 것과 다른 결론인데, 트리 뷰는 펼친 디렉토리만 필요해서 재귀가 낭비지만 status는 트리 전체가 대상이라 더 작은 감시 집합이 없다. 남는 위험(리눅스 inotify 디스크립터 소진)은 **설치 실패 시 예전의 1초 폴링으로 폴백**해서 받는다. 이 폴백은 **끈적하다** — 실패 원인(watch 상한, 권한)은 1초 뒤에 달라지지 않으므로, 재시도는 아무도 안 보던 저장소를 다시 볼 때만 일어난다. 세 가지 상한이 이것을 안전하게 만든다: - **읽기 간격 하한 1초** — 이벤트가 폭주해도 비용이 정확히 예전 폴링과 같고 절대 그보다 크지 않다. -- **10초 상한** — 이벤트를 놓쳤거나 트리 일부에만 감시가 걸렸을 때의 안전망. 감시가 아예 없으면 - 이 값은 쓰이지 않고 1초 폴링이 된다. -- **git이 무시하는 경로는 읽지 않는다** — 빌드 산출물은 워크트리에서 가장 시끄럽고 status에 - 나타날 수 없는 유일한 것이다. `-f`로 추가된, 무시 디렉토리 안의 추적 파일이 이것이 잘못 - 건너뛰는 유일한 경우이고 10초 상한이 잡는다. - -**아무도 안 보는 저장소는 걷지도 감시하지도 않는다**(`SnapshotChannel::watch`). 데몬이 여는 -워커는 **처음부터 잠든 채로 시작한다**(`spawn_asleep`) — 깨워서 만든 뒤 끄면 워커가 그 사이에 -한 번 읽고, 그 낡은 값이 나중의 더 새로운 읽기 뒤에 발행된다. 워커는 순회를 **끝낸 뒤에도** -awake를 한 번 더 보고 잠들었으면 결과를 넘기지 않는다. 첫 구독자가 오면 다시 켜고 **그 자리에서 -한 번 읽어** 답한다: 꺼져 있는 동안의 `latest`는 마지막 클라이언트가 떠날 때의 상태이고, 다음 날 -아침에 연 페이지에는 그것이 낡은 값이 아니라 틀린 화면이다. `/api/status`도 같다. 이 켜고 끄기는 -**구독자 목록 락을 잡은 채로** 결정한다 — 세었다가 놓고 등록하면 그 틈에 마지막 클라이언트가 -떠나며 읽기를 꺼버려, 구독자가 붙어 있는데 아무도 다시 켜지 않는 상태가 남는다. - -- **남은 한계**: 워커가 큐에 넣은 읽기와 구독 시점의 즉시 읽기가 겹치면 오래된 쪽이 뒤에 발행될 - 수 있다. 다음 변화나 10초 안전망이 바로잡는다. 근본 해결(읽기마다 시각을 실어 발행 순서를 읽은 - 순서로 강제)은 `SnapshotMsg`가 TUI와 뷰어 양쪽에 걸쳐 있어 이 창의 크기에 비해 값이 크다. -- **git 디렉토리가 트리 밖에 있으면 그쪽도 감시한다.** `git worktree add`와 `--separate-git-dir`은 - `.git`을 파일로 남긴다. 감시 대상은 `path()`가 아니라 **`commondir()`**인데, linked worktree의 - `path()`에는 자기 index만 있고 ref는 본체 쪽에 있기 때문이다. 이 두 번째 감시는 저장소 핸들이 - 있어야 위치를 물을 수 있으므로 **읽기 뒤에**, 그리고 매 읽기마다 다시 확인한다(핸들은 주기적으로 - 다시 열린다). 감시를 새로 건 직후에는 읽기를 한 번 예약한다. 평범한 저장소는 두 번째 감시를 - 걸지 않는다 — 걸면 모든 이벤트가 두 번 온다. -- `objects`/`logs`/`*.lock` 필터는 **git 디렉토리 최상위에만** 적용한다. 서브모듈 이름은 트리에서의 - 경로라 슬래시를 포함해 `modules/foo/objects/HEAD`를 어떤 방법으로도 구분할 수 없다. 그래서 - 판단하지 않고 읽는다: 잘못 거르면 아무도 못 보는 변경이 생기고, 다 통과시켜도 서브모듈 fetch 중 - 초당 한 번 더 걷는 것이 전부다. -- **macOS는 이벤트 경로를 심링크 해석해서 준다**(`/var/...` → `/private/var/...`). 감시 디렉토리 - 경로를 canonical 형태와 원래 형태 양쪽으로 들고 비교한다 — 이걸 틀리면 정확성은 유지되지만 - **ignore 필터가 조용히 통째로 무력화된다**. -- **이벤트 큐는 한 번에 비운다.** 읽기 한 번(5만 파일 129 ms) 동안 빌드는 수천 개의 이벤트를 - 쌓는데, 하나씩 소비하면 뒤에 도착한 **종료 신호도 그 뒤에서 기다린다**(`Drop`의 join은 5 ms - 상한이라 그대로 detach로 떨어진다). 깨어난 김에 `try_recv`로 전부 받고, 이미 읽기가 예약된 - 상태(`changed`)면 경로마다 ignore 여부를 되묻지 않는다. +- **10초 상한** — 이벤트를 놓쳤거나 트리 일부에만 감시가 걸렸을 때의 안전망. 감시가 아예 없으면 이 값은 쓰이지 않고 1초 폴링이 된다. +- **git이 무시하는 경로는 읽지 않는다** — 빌드 산출물은 워크트리에서 가장 시끄럽고 status에 나타날 수 없는 유일한 것이다. `-f`로 추가된, 무시 디렉토리 안의 추적 파일이 이것이 잘못 건너뛰는 유일한 경우이고 10초 상한이 잡는다. + +**아무도 안 보는 저장소는 걷지도 감시하지도 않는다**(`SnapshotChannel::watch`). 데몬이 여는 워커는 **처음부터 잠든 채로 시작한다**(`spawn_asleep`) — 깨워서 만든 뒤 끄면 워커가 그 사이에 한 번 읽고, 그 낡은 값이 나중의 더 새로운 읽기 뒤에 발행된다. 워커는 순회를 **끝낸 뒤에도** awake를 한 번 더 보고 잠들었으면 결과를 넘기지 않는다. 첫 구독자가 오면 다시 켜고 **그 자리에서 한 번 읽어** 답한다: 꺼져 있는 동안의 `latest`는 마지막 클라이언트가 떠날 때의 상태이고, 다음 날 아침에 연 페이지에는 그것이 낡은 값이 아니라 틀린 화면이다. `/api/status`도 같다. 이 켜고 끄기는 **구독자 목록 락을 잡은 채로** 결정한다 — 세었다가 놓고 등록하면 그 틈에 마지막 클라이언트가 떠나며 읽기를 꺼버려, 구독자가 붙어 있는데 아무도 다시 켜지 않는 상태가 남는다. + +- **남은 한계**: 워커가 큐에 넣은 읽기와 구독 시점의 즉시 읽기가 겹치면 오래된 쪽이 뒤에 발행될 수 있다. 다음 변화나 10초 안전망이 바로잡는다. 근본 해결(읽기마다 시각을 실어 발행 순서를 읽은 순서로 강제)은 `SnapshotMsg`가 TUI와 뷰어 양쪽에 걸쳐 있어 이 창의 크기에 비해 값이 크다. +- **git 디렉토리가 트리 밖에 있으면 그쪽도 감시한다.** `git worktree add`와 `--separate-git-dir`은 `.git`을 파일로 남긴다. 감시 대상은 `path()`가 아니라 **`commondir()`**인데, linked worktree의 `path()`에는 자기 index만 있고 ref는 본체 쪽에 있기 때문이다. 이 두 번째 감시는 저장소 핸들이 있어야 위치를 물을 수 있으므로 **읽기 뒤에**, 그리고 매 읽기마다 다시 확인한다(핸들은 주기적으로 다시 열린다). 감시를 새로 건 직후에는 읽기를 한 번 예약한다. 평범한 저장소는 두 번째 감시를 걸지 않는다 — 걸면 모든 이벤트가 두 번 온다. +- `objects`/`logs`/`*.lock` 필터는 **git 디렉토리 최상위에만** 적용한다. 서브모듈 이름은 트리에서의 경로라 슬래시를 포함해 `modules/foo/objects/HEAD`를 어떤 방법으로도 구분할 수 없다. 그래서 판단하지 않고 읽는다: 잘못 거르면 아무도 못 보는 변경이 생기고, 다 통과시켜도 서브모듈 fetch 중 초당 한 번 더 걷는 것이 전부다. +- **macOS는 이벤트 경로를 심링크 해석해서 준다**(`/var/...` → `/private/var/...`). 감시 디렉토리 경로를 canonical 형태와 원래 형태 양쪽으로 들고 비교한다 — 이걸 틀리면 정확성은 유지되지만 **ignore 필터가 조용히 통째로 무력화된다**. +- **이벤트 큐는 한 번에 비운다.** 읽기 한 번(5만 파일 129 ms) 동안 빌드는 수천 개의 이벤트를 쌓는데, 하나씩 소비하면 뒤에 도착한 **종료 신호도 그 뒤에서 기다린다**(`Drop`의 join은 5 ms 상한이라 그대로 detach로 떨어진다). 깨어난 김에 `try_recv`로 전부 받고, 이미 읽기가 예약된 상태(`changed`)면 경로마다 ignore 여부를 되묻지 않는다. ### 스크롤백과 재접속 -**스크롤백 깊이는 두 상한이 만나는 자리다** — 허브는 pane당 바이트 링(256 KiB), 클라이언트는 -줄(1000)로 센다. 평범한 출력에서는 클라이언트의 줄 상한이 먼저 차지만, **줄당 ~262바이트를 -넘으면 리플레이가 줄 상한을 못 채운다**(토큰마다 색을 바꾸는 하이라이팅이 거기에 닿는다). 그 -지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 텍스트가 아니라 -repaint 시퀀스이고, 상한은 저장소×pane마다 지불된다. - -**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`session/terminal/hub_modes.rs`, -`runtime/emulator/{modes,snapshot}.rs`). 바이트 링은 역사이지 스냅샷이 아니어서, 프로그램이 -시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate screen, 마우스 리포팅, bracketed paste, -DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이언트는 **프로그램이 설정한 적 없는 -터미널**이 된다(스크롤·클릭 죽음, 화살표 인코딩 불일치, 붙여넣기 깨짐). - -- 허브가 pane당 에뮬레이터를 돌려 현재 모드를 `PaneState`에 적고(처음엔 모드 확인용이었지만 - 지금은 스냅샷이 그리드도 읽는다 — 아래), `connect`가 history보다 **먼저** - `PaneModes::prelude`를 보낸다. 프렐류드는 - 12개 모드를 h/l로 **전부 명시**한다 — 받는 쪽은 xterm.js고 그 기본값은 이 에뮬레이터의 것이 - 아니다(`1007`이 실제로 다르다). -- **pane 제목도 같은 이유로 여기서 따라간다.** OSC 0/2는 프로그램이 시작할 때 한 번 보내고 마는 - 것이라 모드와 성질이 똑같다. 클라이언트마다 각자 읽게 두었더니 그 바이트가 지나갈 때 붙어 - 있던 화면만 이름을 알았고, 나중에 온 페이지도 재접속한 페이지도 위치 라벨(`term 1`)로 - 돌아갔다 — 에이전트를 띄워 둔 pane이 세션 내내 그렇게 보였다. 이제 허브가 최신 제목을 - `PaneState.title`에 적고 `Created`에 실어 보낸다. **붙어 있는 클라이언트에게 따로 알리지는 - 않는다** — 그들은 제목을 세팅한 바이트 자체를 받고 있고 각자 에뮬레이터가 그것을 읽는다. - 자식이 고르는 문자열이므로 들어올 때 `MAX_PANE_TITLE_CHARS`로 자른다. -- **alternate screen pane은 링을 replay하지 않는다**: 그 바이트는 이 클라이언트에 없는 화면에 - 대한 셀 갱신이고, 전사는 프로그램 자신의 메모리에 있다. 대신 **허브가 화면을 갖고 있다가 그것을 - 준다**. 모드 추적용으로 이미 pane마다 돌고 있는 에뮬레이터가 그 셀 갱신이 만들어낸 화면을 들고 - 있으므로, `PaneEmulator::screen_snapshot`이 그것을 다시 바이트로 쓴다 — 행마다 `CUP`, 속성 런마다 - reset으로 시작하는 `SGR` 하나, 그리고 프로그램이 남긴 pen과 커서. **절대 repaint**라서 받는 쪽의 - 커서·속성이 무엇이었든 결과가 같다. VS Code의 pty host가 headless xterm.js + SerializeAddon으로 - 하는 것과 같은 모델이고, tmux·mosh도 서버가 화면을 소유한다. -- **normal screen도 화면은 스냅샷이 진다: 링 + `covered` + `normal_screen`.** 링이 화면과 - 역사를 겸하던 시절의 구멍: 제자리 repaint만 하는 프로그램(Claude Code의 입력 박스, 스피너)은 - **스크롤 없이** 링을 회전시키므로, 오래 방치한 pane은 화면 상단을 그린 바이트가 밀려나고 - 재접속한 클라이언트는 하단 박스만 남은 빈 화면을 봤다. 이제 normal replay는 - `링[..covered]`(역사) → `normal_screen`(covered 시점 화면의 절대 repaint) → - `링[covered..]`(스냅샷 이후 전부) 순서다. 스냅샷의 `2J`가 잘린 역사가 남긴 viewport 잔해를 - 지우고 화면을 온전히 다시 그리며, 스크롤백으로 넘어간 줄은 `2J`가 건드리지 않아 역사도 - 보존된다. eviction은 `covered` 앞에서만 일어난다 — 마크 뒤 tail은 스냅샷 위에 얹혀야 할 - 바이트라 하나도 버릴 수 없고(alt `since`와 같은 규칙), tail이 상한을 넘으면 새 스냅샷을 떠 - 마크를 옮긴다. 갱신은 그때와 resize 때뿐이다: alt처럼 tick마다 갱신하지 않는 이유는, tail - replay가 정확성을 이미 보장해서 스냅샷 비용이 링 한 바퀴(256 KiB)당 한 번이면 충분하기 - 때문이다. 역사를 링 대신 에뮬레이터에 들리는 안(줄 단위 history 직렬화)은 기각했다 — - pane당 메모리가 수십 MB로 뛰고, Claude Code류의 전사는 어차피 프로그램 자신의 메모리에 - 있어 얻는 것이 없다. -- **스냅샷 앵커는 시퀀스가 닫힌 chunk 경계만 잡는다**(`runtime/emulator/boundary.rs`). - PTY read는 임의 바이트 위치에서 끊기므로 chunk가 escape 시퀀스나 멀티바이트 문자 한가운데서 - 끝날 수 있는데, 거기에 스냅샷을 접합하면 재접속 클라이언트에게 시퀀스의 꼬리가 일반 입력으로 - 도착한다(`ESC [ 2`가 이음매 앞, 뒤에 온 `J`는 화면에 글자로 찍힌다). 에뮬레이터가 파서 상태 - 기계의 골격만 미러링해 "시퀀스가 열려 있는가"를 답하고, 열려 있으면 스냅샷을 다음 깨끗한 - chunk로 미룬다 — crowded 신호는 chunk마다 반복되므로 미룬 스냅샷은 저절로 재시도된다. - 미러는 정직하게만 답하고(파서가 시퀀스 중인데 경계라고 답하는 방향의 발산이 없다), 무한 - 미루기는 호출부가 막는다: 스냅샷을 기다리는 기록이 링 **두 바퀴**를 넘으면(desperate) 실제 - 시퀀스가 그만큼 열려 있을 리 없으므로 깨진 스트림으로 치고 이음매를 감수하며 스냅샷한다. - DEC 2026 synchronized update 동안은 Processor가 바이트를 그리드에 적용하지 않고 버퍼링하므로, - 시퀀스가 다 닫혀 있어도 `sync_bytes_count`가 0이 될 때까지 경계가 아니다 — 아니면 기록은 - 덮었다고 세는 바이트가 스냅샷에는 없다. desperate가 우회하는 것은 시퀀스 이음매뿐이고 이 - sync 조건은 우회하지 못한다 — 이음매는 화면에 한 번 찍히는 잡음이지만 누락은 틀린 화면이며, - sync는 ESU 아니면 Processor 자체 버퍼 상한(2 MiB)에서 반드시 끝나므로 우회 없이도 유계다. - alt의 진입·tick·resize 갱신도 같은 게이트를 탄다. -- **프로그램에게는 아무것도 청구하지 않는다.** 예전에는 크기를 한 행 줄였다 되돌려 `SIGWINCH`로 - 다시 그리게 했는데, 그것은 부탁이고 부탁은 거절될 수 있다: 네트워크에 막힌 프로그램은 안 그리고, - 요청은 유계 큐에 실려 가득 차면 버려지고, 재접속 폭풍을 막던 pane당 최소 간격은 요청을 **미루지 - 않고 버려서** 마지막으로 성공한 연결이 굶었다(1초 재연결 타이머 대 2초 간격 — 두 번째 시도가 - 항상 창 안에 들어온다). resize가 대신해 주지도 못한다. 재접속한 클라이언트의 레이아웃은 보통 - 끊기기 전과 같아서 resize를 아예 보내지 않는다([web.md](web.md)의 "PTY 크기는 확정된 값만 - 전달한다"). -- **이 전제를 잃어 실제 사고가 났다** — 예전에는 붙자마자 오는 resize에 repaint를 기대고 있었는데, - 리로드 플리커를 없애려 같은 크기면 resize를 생략하면서 그 repaint가 사라졌다. 사용자가 깨진 - 화면에 누르는 복구 키가 `Ctrl+L`이고 fullscreen Claude Code는 그것을 2초 안에 두 번 받으면 - `/clear`를 실행한다 — 대화가 연달아 지워졌다. 그때 들어온 repaint 요청 방식이 위의 구멍들을 - 남겼고, 허브가 화면을 직접 갖는 것이 그 구멍을 닫는다. -- **에뮬레이터가 셀을 읽으므로 resize를 따라가야 한다**(`hub_layout::resize_pane`). 그리드가 틀린 - 폭이면 자식이 감지 않는 곳에서 감아 다른 클라이언트와 다른 화면을 준다. 예전에는 모드만 봤으므로 - 그리드가 파서의 스크래치였고 resize를 따르지 않았다. resize는 지금 pane이 올라가 있는 화면의 - 스냅샷도 reflow된 그리드로 갱신한다 — 자식이 `SIGWINCH`에 안 그려도 그 사이에 붙는 - 클라이언트가 옛 크기의 화면을 받지 않게. 반대쪽 기록(alt 밑에 동결된 normal 스냅샷)은 - 에뮬레이터의 비활성 그리드라 읽을 수 없어 옛 크기로 남는다 — 복귀 후의 출력이 tail로 그 위에 - 얹히고, 다음 스냅샷 갱신이 마저 고친다. -- **꺼내 둔 resize도 연결 수명을 넘지 못한다.** latest-value map에서 worker가 값을 꺼낸 직후 - 소유자가 떠날 수 있으므로 요청은 원래 connection id를 함께 보존한다. 적용 시 hub state를 먼저 - 잠가 같은 client/connection 등록인지 확인하고, 그 lock 아래 session ownership을 다시 검사한다 - (`state → ownership`, `connect`와 같은 순서). 따라서 disconnect나 소유권 이전이 먼저 완료되면 - 이미 꺼낸 옛 요청도 새 소유자의 PTY에 적용하거나 ACK하지 않는다. -- **화면 하나로는 부족하다: `screen` + `since`.** 스냅샷은 worker tick마다 갱신되므로 chunk가 - broadcast된 뒤 그것이 스냅샷에 반영되기 전에 클라이언트가 붙을 수 있다. 그래서 스냅샷 이후 - broadcast된 바이트를 옆에 함께 들고, replay는 `screen` 다음에 `since`를 보낸다 — 둘을 합치면 - 이미 붙어 있는 클라이언트가 본 것과 정확히 같다. **`since`에서 바이트를 버리지 않는다**(터미널 - 바이트는 건너뛸 수 없다). 상한을 넘으면 새 스냅샷을 떠서 비운다. 버퍼를 전환하는 chunk는 - 끝이 깨끗하면 tick을 기다리지 않고 즉시 스냅샷한다 — 전환 전 화면 위에 전환 후 바이트가 - 얹히는 창을 남기지 않기 위해서다. 시퀀스 중간에 잘린 전환 chunk는 `since`로 들어가고 스트림이 - 닫힌 뒤의 갱신이 화면을 가져간다 — 그때까지 붙는 클라이언트는 그 chunk를 raw로 받아, 전환 전 - 텍스트가 잠깐 alt 버퍼에 찍힌다(다음 paint가 덮는다). 열린 시퀀스에 접합하지 않는 값이다. -- **alternate screen 동안 normal 기록은 동결된다.** 그 바이트는 링에 쓸모없고, 프로그램이 - 전환한 시점의 기록이 곧 그가 돌아갈 normal screen이다. 그래서 alt pane의 replay는 `1049l` + - normal 기록(링[..covered] + `normal_screen` + 링[covered..]) → prelude(`1049h`) → - `screen` + `since` 순서다. 복귀(`1049l`) 때도 동결된 normal 기록은 그대로 유효하다 — - 에뮬레이터의 normal 그리드는 alt 그리기에 건드려지지 않았다. 이것이 없을 때는 full-screen 프로그램을 - 종료하면 그 도중에 붙은 클라이언트가 빈 화면을 봤다. 남은 한계: 전환이 일어난 **그 chunk**의 - 전환 이전 텍스트는 링에 들어가지 않는다(chunk 단위로 기록하므로). 같은 뿌리의 한계 하나 더: - 분류가 chunk 처리 **후의** 모드를 읽으므로, synchronized update가 chunk 경계에 걸친 채 - 화면 전환을 품으면 BSU가 든 chunk와 ESU가 든 chunk가 다른 기록으로 갈라져, 재접속 replay에 - ESU 없는 BSU가 남을 수 있다 — 그 클라이언트는 자기 sync 타임아웃까지 화면을 들고 있다가 - 다음 출력에 회복한다. 근본 해결은 sync가 잡고 있는 바이트를 기록에서도 보류했다가 확정된 - 모드로 분류하는 것인데, 필요해지면 그때 올린다. -- **replay는 1 MiB 프레임으로 쪼개 보낸다**(`REPLAY_CHUNK_BYTES`). 클라이언트는 받은 바이트를 - 파서에 이어 붙일 뿐이고 그 파서는 write 경계를 넘어 상태를 유지하므로, 프레임 경계는 아무 의미가 - 없다 — 쪼개는 것이 공짜다. 반면 프레임 하나에는 상한이 있다: 데몬 소켓은 4 MiB를 넘는 payload를 - 거부하고(`daemon/frame.rs`), 링과 달리 화면은 **pane 면적에 비례해** 커져서 큰 pane을 셀마다 다른 - 색으로 덮으면(truecolor 이미지 렌더러가 그렇게 한다) 수 MB에 이른다. 통째로 보내면 attach 연결이 - 끊기고, 재접속해도 같은 화면을 다시 보내므로 반복해서 끊긴다. 화면을 하나의 쪼갤 수 없는 메시지로 - 보내는 구현은 없다 — VS Code의 replay는 엔트리 배열이고, tmux는 넘겨받은 파일 디스크립터에 직접 - 쓰고, mosh의 데이터그램은 화면을 담을 수조차 없다. 1 MiB는 상한에 여유를 두면서, 이 허브가 - 허용하는 가장 큰 pane들의 replay 전체가 `CLIENT_QUEUE_DEPTH` 안에 들어오게 한다 — 그것이 클라이언트 - 등록 **전에** 큐에 밀어 넣어도 안전한 이유다(그 큐에 쓰는 것이 아직 아무도 없다). -- **스냅샷이 나르지 않는 것**: wrap 기록(`WRAPLINE`, 마지막 칼럼에서 밀린 wide char의 - `LEADING_WIDE_CHAR_SPACER`) — 절대 repaint는 행을 독립적으로 놓으므로 감긴 행이 두 행으로 - 도착하고 이후 resize에서 다르게 reflow된다. 지금 그 차이를 읽는 것은 없다(alt 프로그램은 resize에 - 다시 그리고, normal pane의 **역사**는 여전히 링으로 replay되어 wrap이 보존된다 — 스냅샷이 대신하는 - 것은 화면뿐이다). underline 색, 하이퍼링크(OSC 8), 스크롤 리전(DECSTBM)도 나르지 않는다. - -**입력의 출처를 기록한다**(`session/terminal/hub_diag.rs`, `session.rs`, -`viewer-ui/src/lib/clearKeyProbe.ts`). 특정 사건 때문에 존재하는 계측이다 — 5초 사이에 대화가 -14번 지워졌는데 `0x0c`가 30번쯤 기계적 간격으로 들어왔다는 뜻이고, **무엇이 보냈는지 알 수 -없었다**. nightcrow가 합성하는 입력은 스크롤·마우스 리포트와 plugin의 `continue`뿐이고 후자는 그 -자리에서 로그를 남기므로, 남는 것은 클라이언트의 입력이다. - -- **도착 기록** — 허브가 `0x0c`가 실린 입력 프레임마다 pane·client id·개수·동승 바이트 수·직전 - 프레임과의 간격·연속 구간 누계를 남긴다. 키보드에서 온 `^L`은 혼자 오고 paste나 스크립트가 쓴 - 블록은 그렇지 않으므로 **동승 바이트 수와 간격만으로 모양이 갈린다**. 한 구간에서 40줄까지만 - 쓰고 나머지는 세기만 한다 — 눌린 채 반복되는 키는 초당 수십 번이라 로그가 스스로를 밀어낸다. -- **출처 지문** — 브라우저가 `0x0c`를 보낼 때 그것을 만든 keydown의 - `isTrusted`·`repeat`·`code`·경과 ms를 함께 보고한다. `isTrusted:false`는 **확장 확정**, - `true`+`repeat:true`는 물리적 키 반복, keydown 없이 온 바이트는 paste·IME·직접 주입이다. -- **입력 내용은 어느 쪽도 기록하지 않는다** — 세는 것과 타이밍뿐이다. 보고는 클라이언트가 하는 - 말이므로 분당 상한을 두고, `code`는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을 - 위조할 수 있다). 원인이 특정되면 이 계측은 지운다. +**스크롤백 깊이는 두 상한이 만나는 자리다** — 허브는 pane당 바이트 링(256 KiB), 클라이언트는 줄(1000)로 센다. 평범한 출력에서는 클라이언트의 줄 상한이 먼저 차지만, **줄당 ~262바이트를 넘으면 리플레이가 줄 상한을 못 채운다**(토큰마다 색을 바꾸는 하이라이팅이 거기에 닿는다). 그 지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 텍스트가 아니라 repaint 시퀀스이고, 상한은 저장소×pane마다 지불된다. + +**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`session/terminal/hub_modes.rs`, `runtime/emulator/{modes,snapshot}.rs`). 바이트 링은 역사이지 스냅샷이 아니어서, 프로그램이 시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate screen, 마우스 리포팅, bracketed paste, DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이언트는 **프로그램이 설정한 적 없는 터미널**이 된다(스크롤·클릭 죽음, 화살표 인코딩 불일치, 붙여넣기 깨짐). + +- 허브가 pane당 에뮬레이터를 돌려 현재 모드를 `PaneState`에 적고(처음엔 모드 확인용이었지만 지금은 스냅샷이 그리드도 읽는다 — 아래), `connect`가 history보다 **먼저** `PaneModes::prelude`를 보낸다. 프렐류드는 12개 모드를 h/l로 **전부 명시**한다 — 받는 쪽은 xterm.js고 그 기본값은 이 에뮬레이터의 것이 아니다(`1007`이 실제로 다르다). +- **pane 제목도 같은 이유로 여기서 따라간다.** OSC 0/2는 프로그램이 시작할 때 한 번 보내고 마는 것이라 모드와 성질이 똑같다. 클라이언트마다 각자 읽게 두었더니 그 바이트가 지나갈 때 붙어 있던 화면만 이름을 알았고, 나중에 온 페이지도 재접속한 페이지도 위치 라벨(`term 1`)로 돌아갔다 — 에이전트를 띄워 둔 pane이 세션 내내 그렇게 보였다. 이제 허브가 최신 제목을 `PaneState.title`에 적고 `Created`에 실어 보낸다. **붙어 있는 클라이언트에게 따로 알리지는 않는다** — 그들은 제목을 세팅한 바이트 자체를 받고 있고 각자 에뮬레이터가 그것을 읽는다. 자식이 고르는 문자열이므로 들어올 때 `MAX_PANE_TITLE_CHARS`로 자른다. +- **alternate screen pane은 링을 replay하지 않는다**: 그 바이트는 이 클라이언트에 없는 화면에 대한 셀 갱신이고, 전사는 프로그램 자신의 메모리에 있다. 대신 **허브가 화면을 갖고 있다가 그것을 준다**. 모드 추적용으로 이미 pane마다 돌고 있는 에뮬레이터가 그 셀 갱신이 만들어낸 화면을 들고 있으므로, `PaneEmulator::screen_snapshot`이 그것을 다시 바이트로 쓴다 — 행마다 `CUP`, 속성 런마다 reset으로 시작하는 `SGR` 하나, 그리고 프로그램이 남긴 pen과 커서. **절대 repaint**라서 받는 쪽의 커서·속성이 무엇이었든 결과가 같다. VS Code의 pty host가 headless xterm.js + SerializeAddon으로 하는 것과 같은 모델이고, tmux·mosh도 서버가 화면을 소유한다. +- **normal screen도 화면은 스냅샷이 진다: 링 + `covered` + `normal_screen`.** 링이 화면과 역사를 겸하던 시절의 구멍: 제자리 repaint만 하는 프로그램(Claude Code의 입력 박스, 스피너)은 **스크롤 없이** 링을 회전시키므로, 오래 방치한 pane은 화면 상단을 그린 바이트가 밀려나고 재접속한 클라이언트는 하단 박스만 남은 빈 화면을 봤다. 이제 normal replay는 `링[..covered]`(역사) → `normal_screen`(covered 시점 화면의 절대 repaint) → `링[covered..]`(스냅샷 이후 전부) 순서다. 스냅샷의 `2J`가 잘린 역사가 남긴 viewport 잔해를 지우고 화면을 온전히 다시 그리며, 스크롤백으로 넘어간 줄은 `2J`가 건드리지 않아 역사도 보존된다. eviction은 `covered` 앞에서만 일어난다 — 마크 뒤 tail은 스냅샷 위에 얹혀야 할 바이트라 하나도 버릴 수 없고(alt `since`와 같은 규칙), tail이 상한을 넘으면 새 스냅샷을 떠 마크를 옮긴다. 갱신은 그때와 resize 때뿐이다: alt처럼 tick마다 갱신하지 않는 이유는, tail replay가 정확성을 이미 보장해서 스냅샷 비용이 링 한 바퀴(256 KiB)당 한 번이면 충분하기 때문이다. 역사를 링 대신 에뮬레이터에 들리는 안(줄 단위 history 직렬화)은 기각했다 — pane당 메모리가 수십 MB로 뛰고, Claude Code류의 전사는 어차피 프로그램 자신의 메모리에 있어 얻는 것이 없다. +- **스냅샷 앵커는 시퀀스가 닫힌 chunk 경계만 잡는다**(`runtime/emulator/boundary.rs`). PTY read는 임의 바이트 위치에서 끊기므로 chunk가 escape 시퀀스나 멀티바이트 문자 한가운데서 끝날 수 있는데, 거기에 스냅샷을 접합하면 재접속 클라이언트에게 시퀀스의 꼬리가 일반 입력으로 도착한다(`ESC [ 2`가 이음매 앞, 뒤에 온 `J`는 화면에 글자로 찍힌다). 에뮬레이터가 파서 상태 기계의 골격만 미러링해 "시퀀스가 열려 있는가"를 답하고, 열려 있으면 스냅샷을 다음 깨끗한 chunk로 미룬다 — crowded 신호는 chunk마다 반복되므로 미룬 스냅샷은 저절로 재시도된다. 미러는 정직하게만 답하고(파서가 시퀀스 중인데 경계라고 답하는 방향의 발산이 없다), 무한 미루기는 호출부가 막는다: 스냅샷을 기다리는 기록이 링 **두 바퀴**를 넘으면(desperate) 실제 시퀀스가 그만큼 열려 있을 리 없으므로 깨진 스트림으로 치고 이음매를 감수하며 스냅샷한다. DEC 2026 synchronized update 동안은 Processor가 바이트를 그리드에 적용하지 않고 버퍼링하므로, 시퀀스가 다 닫혀 있어도 `sync_bytes_count`가 0이 될 때까지 경계가 아니다 — 아니면 기록은 덮었다고 세는 바이트가 스냅샷에는 없다. desperate가 우회하는 것은 시퀀스 이음매뿐이고 이 sync 조건은 우회하지 못한다 — 이음매는 화면에 한 번 찍히는 잡음이지만 누락은 틀린 화면이며, sync는 ESU 아니면 Processor 자체 버퍼 상한(2 MiB)에서 반드시 끝나므로 우회 없이도 유계다. alt의 진입·tick·resize 갱신도 같은 게이트를 탄다. +- **프로그램에게는 아무것도 청구하지 않는다.** 예전에는 크기를 한 행 줄였다 되돌려 `SIGWINCH`로 다시 그리게 했는데, 그것은 부탁이고 부탁은 거절될 수 있다: 네트워크에 막힌 프로그램은 안 그리고, 요청은 유계 큐에 실려 가득 차면 버려지고, 재접속 폭풍을 막던 pane당 최소 간격은 요청을 **미루지 않고 버려서** 마지막으로 성공한 연결이 굶었다(1초 재연결 타이머 대 2초 간격 — 두 번째 시도가 항상 창 안에 들어온다). resize가 대신해 주지도 못한다. 재접속한 클라이언트의 레이아웃은 보통 끊기기 전과 같아서 resize를 아예 보내지 않는다([web.md](web.md)의 "PTY 크기는 확정된 값만 전달한다"). +- **이 전제를 잃어 실제 사고가 났다** — 예전에는 붙자마자 오는 resize에 repaint를 기대고 있었는데, 리로드 플리커를 없애려 같은 크기면 resize를 생략하면서 그 repaint가 사라졌다. 사용자가 깨진 화면에 누르는 복구 키가 `Ctrl+L`이고 fullscreen Claude Code는 그것을 2초 안에 두 번 받으면 `/clear`를 실행한다 — 대화가 연달아 지워졌다. 그때 들어온 repaint 요청 방식이 위의 구멍들을 남겼고, 허브가 화면을 직접 갖는 것이 그 구멍을 닫는다. +- **에뮬레이터가 셀을 읽으므로 resize를 따라가야 한다**(`hub_layout::resize_pane`). 그리드가 틀린 폭이면 자식이 감지 않는 곳에서 감아 다른 클라이언트와 다른 화면을 준다. 예전에는 모드만 봤으므로 그리드가 파서의 스크래치였고 resize를 따르지 않았다. resize는 지금 pane이 올라가 있는 화면의 스냅샷도 reflow된 그리드로 갱신한다 — 자식이 `SIGWINCH`에 안 그려도 그 사이에 붙는 클라이언트가 옛 크기의 화면을 받지 않게. 반대쪽 기록(alt 밑에 동결된 normal 스냅샷)은 에뮬레이터의 비활성 그리드라 읽을 수 없어 옛 크기로 남는다 — 복귀 후의 출력이 tail로 그 위에 얹히고, 다음 스냅샷 갱신이 마저 고친다. +- **꺼내 둔 resize도 연결 수명을 넘지 못한다.** latest-value map에서 worker가 값을 꺼낸 직후 소유자가 떠날 수 있으므로 요청은 원래 connection id를 함께 보존한다. 적용 시 hub state를 먼저 잠가 같은 client/connection 등록인지 확인하고, 그 lock 아래 session ownership을 다시 검사한다 (`state → ownership`, `connect`와 같은 순서). 따라서 disconnect나 소유권 이전이 먼저 완료되면 이미 꺼낸 옛 요청도 새 소유자의 PTY에 적용하거나 ACK하지 않는다. +- **화면 하나로는 부족하다: `screen` + `since`.** 스냅샷은 worker tick마다 갱신되므로 chunk가 broadcast된 뒤 그것이 스냅샷에 반영되기 전에 클라이언트가 붙을 수 있다. 그래서 스냅샷 이후 broadcast된 바이트를 옆에 함께 들고, replay는 `screen` 다음에 `since`를 보낸다 — 둘을 합치면 이미 붙어 있는 클라이언트가 본 것과 정확히 같다. **`since`에서 바이트를 버리지 않는다**(터미널 바이트는 건너뛸 수 없다). 상한을 넘으면 새 스냅샷을 떠서 비운다. 버퍼를 전환하는 chunk는 끝이 깨끗하면 tick을 기다리지 않고 즉시 스냅샷한다 — 전환 전 화면 위에 전환 후 바이트가 얹히는 창을 남기지 않기 위해서다. 시퀀스 중간에 잘린 전환 chunk는 `since`로 들어가고 스트림이 닫힌 뒤의 갱신이 화면을 가져간다 — 그때까지 붙는 클라이언트는 그 chunk를 raw로 받아, 전환 전 텍스트가 잠깐 alt 버퍼에 찍힌다(다음 paint가 덮는다). 열린 시퀀스에 접합하지 않는 값이다. +- **alternate screen 동안 normal 기록은 동결된다.** 그 바이트는 링에 쓸모없고, 프로그램이 전환한 시점의 기록이 곧 그가 돌아갈 normal screen이다. 그래서 alt pane의 replay는 `1049l` + normal 기록(링[..covered] + `normal_screen` + 링[covered..]) → prelude(`1049h`) → `screen` + `since` 순서다. 복귀(`1049l`) 때도 동결된 normal 기록은 그대로 유효하다 — 에뮬레이터의 normal 그리드는 alt 그리기에 건드려지지 않았다. 이것이 없을 때는 full-screen 프로그램을 종료하면 그 도중에 붙은 클라이언트가 빈 화면을 봤다. 남은 한계: 전환이 일어난 **그 chunk**의 전환 이전 텍스트는 링에 들어가지 않는다(chunk 단위로 기록하므로). 같은 뿌리의 한계 하나 더: 분류가 chunk 처리 **후의** 모드를 읽으므로, synchronized update가 chunk 경계에 걸친 채 화면 전환을 품으면 BSU가 든 chunk와 ESU가 든 chunk가 다른 기록으로 갈라져, 재접속 replay에 ESU 없는 BSU가 남을 수 있다 — 그 클라이언트는 자기 sync 타임아웃까지 화면을 들고 있다가 다음 출력에 회복한다. 근본 해결은 sync가 잡고 있는 바이트를 기록에서도 보류했다가 확정된 모드로 분류하는 것인데, 필요해지면 그때 올린다. +- **replay는 1 MiB 프레임으로 쪼개 보낸다**(`REPLAY_CHUNK_BYTES`). 클라이언트는 받은 바이트를 파서에 이어 붙일 뿐이고 그 파서는 write 경계를 넘어 상태를 유지하므로, 프레임 경계는 아무 의미가 없다 — 쪼개는 것이 공짜다. 반면 프레임 하나에는 상한이 있다: 데몬 소켓은 4 MiB를 넘는 payload를 거부하고(`daemon/frame.rs`), 링과 달리 화면은 **pane 면적에 비례해** 커져서 큰 pane을 셀마다 다른 색으로 덮으면(truecolor 이미지 렌더러가 그렇게 한다) 수 MB에 이른다. 통째로 보내면 attach 연결이 끊기고, 재접속해도 같은 화면을 다시 보내므로 반복해서 끊긴다. 화면을 하나의 쪼갤 수 없는 메시지로 보내는 구현은 없다 — VS Code의 replay는 엔트리 배열이고, tmux는 넘겨받은 파일 디스크립터에 직접 쓰고, mosh의 데이터그램은 화면을 담을 수조차 없다. 1 MiB는 상한에 여유를 두면서, 이 허브가 허용하는 가장 큰 pane들의 replay 전체가 `CLIENT_QUEUE_DEPTH` 안에 들어오게 한다 — 그것이 클라이언트 등록 **전에** 큐에 밀어 넣어도 안전한 이유다(그 큐에 쓰는 것이 아직 아무도 없다). +- **스냅샷이 나르지 않는 것**: wrap 기록(`WRAPLINE`, 마지막 칼럼에서 밀린 wide char의 `LEADING_WIDE_CHAR_SPACER`) — 절대 repaint는 행을 독립적으로 놓으므로 감긴 행이 두 행으로 도착하고 이후 resize에서 다르게 reflow된다. 지금 그 차이를 읽는 것은 없다(alt 프로그램은 resize에 다시 그리고, normal pane의 **역사**는 여전히 링으로 replay되어 wrap이 보존된다 — 스냅샷이 대신하는 것은 화면뿐이다). underline 색, 하이퍼링크(OSC 8), 스크롤 리전(DECSTBM)도 나르지 않는다. + +**입력의 출처를 기록한다**(`session/terminal/hub_diag.rs`, `session.rs`, `viewer-ui/src/lib/clearKeyProbe.ts`). 특정 사건 때문에 존재하는 계측이다 — 5초 사이에 대화가 14번 지워졌는데 `0x0c`가 30번쯤 기계적 간격으로 들어왔다는 뜻이고, **무엇이 보냈는지 알 수 없었다**. nightcrow가 합성하는 입력은 스크롤·마우스 리포트와 plugin의 `continue`뿐이고 후자는 그 자리에서 로그를 남기므로, 남는 것은 클라이언트의 입력이다. + +- **도착 기록** — 허브가 `0x0c`가 실린 입력 프레임마다 pane·client id·개수·동승 바이트 수·직전 프레임과의 간격·연속 구간 누계를 남긴다. 키보드에서 온 `^L`은 혼자 오고 paste나 스크립트가 쓴 블록은 그렇지 않으므로 **동승 바이트 수와 간격만으로 모양이 갈린다**. 한 구간에서 40줄까지만 쓰고 나머지는 세기만 한다 — 눌린 채 반복되는 키는 초당 수십 번이라 로그가 스스로를 밀어낸다. +- **출처 지문** — 브라우저가 `0x0c`를 보낼 때 그것을 만든 keydown의 `isTrusted`·`repeat`·`code`·경과 ms를 함께 보고한다. `isTrusted:false`는 **확장 확정**, `true`+`repeat:true`는 물리적 키 반복, keydown 없이 온 바이트는 paste·IME·직접 주입이다. +- **입력 내용은 어느 쪽도 기록하지 않는다** — 세는 것과 타이밍뿐이다. 보고는 클라이언트가 하는 말이므로 분당 상한을 두고, `code`는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을 위조할 수 있다). 원인이 특정되면 이 계측은 지운다. ## Config Reload (`session/reload.rs`) -`config.toml`을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가 -작업 중이던 것까지. 그래서 **두 테이블만 다시 읽는다.** 무엇이 즉시 닿고 무엇이 안 닿는지는 -"그 값을 이미 무엇에 썼는가"가 정한다. - -- **`[[plugin]]` — 열려 있는 모든 프로젝트에 즉시.** plugin은 pane이 아니라 자식 프로세스라 교체 - 비용이 세션에 없다. hub별로 diff한다(`terminal/hub_reload.rs`): 새로 원하게 된 것을 띄우고, - 아닌 것을 멈추고, **`command`/`args`/`env`가 바뀐 것만** 프로세스를 갈아치운다. - `allowed_resume_flags`·`watch_on_signal`만 바뀌면 살아 있는 자식을 건드리지 않는데, 그 둘은 - 판정마다 이쪽에서 읽는 값이고 plugin은 몇 시간짜리 대기 중일 수 있기 때문이다. -- **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 **딱 한 - 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 - 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 - (`catalog/config_tables.rs`) 그 뒤 runtime reconcile이 띄우는 hub가 새 목록을 받는다. -- **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 - 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`. - -**전송 계층에 독립적이다.** `session.rs`와 같은 자리에 같은 이유로 둔다 — 브라우저는 -`POST /api/reload`, attach한 TUI는 `ClientMessage::ReloadConfig`로 닿고, 둘이 **같은 상태 변경**에 -착지해야 한다. 여기서 인증하지 않는 것도 `session.rs`와 같다(누가 물어볼 수 있는지는 각 전송이 -정한다). 요청은 **아무것도 실어 나르지 않는다** — 파일 자체가 요청이다. 내용을 실어 보내게 하면 -클라이언트가 지어낸 설정으로 세션을 재구성할 수 있다. - -**절반만 적용되지 않는다.** 파일 전체를 파싱·검증한 뒤에야 아무것이든 건드린다. **파일이 사라진 -경우는 거부한다** — 시작 시에는 "아직 설정 없음"이 정상이지만 reload 시점에는 실수이고, 기본값으로 -읽으면 파일을 지우고 reload하는 것이 모든 plugin을 조용히 멈추는 경로가 된다. `--exec` pane은 -파일에 없으므로 Catalog가 따로 기억해 다시 병합한다(`config::merge_startup_commands`). - -**hub에서 무엇이 plugin을 원하는지는 그 hub의 opt-in으로 판정한다** — 새 파일의 것이 아니다. -편집으로 추가된 `[[startup_command]]`는 이미 뜬 hub에 pane이 없으니 그것이 가리키는 plugin을 -띄우면 영영 아무것도 받을 수 없는 자식이 된다. 반대로 **살아 있는 pane을 보고 있는 plugin은 -아무것도 그것을 지명하지 않아도 유지한다**: 살아 있는 agent 터미널을 조용히 감시 해제하는 쪽이 -더 나쁘다. 멈추라는 뜻은 `enabled = false`이고 그건 따른다. - -- **pane의 opt-in은 host가 없어도 기록한다**(`hub_plugins.rs`의 `intended`). 이것이 세션 중간에 - plugin을 켰을 때 그것이 꺼져 있는 동안 만들어진 pane에 닿게 하는 유일한 경로다. 그 자체로는 - 아무 권한도 주지 않는다: pane에 실제로 작용하는 것은 `owners`뿐이다. reload로 멈춘 plugin은 - pane을 놓아주되 opt-in은 남기므로 **끄고 다시 켜면 처음 켜는 것과 같은 자리에 착지한다**. -- **후계자가 뜨지 못하면 그 pane들도 놓아준다**(`Plugins::abandon`). 교체는 멈춘 plugin이 살아 - 있는 pane을 계속 붙잡는 유일한 경우인데 그 근거는 곧 후계자가 온다는 것뿐이다. spawn이 실패하면 - host 없는 이름이 pane을 소유한 채 남고, 그 pane이 다음에 끝날 때 아무도 부탁할 수 없는 9일짜리 - hold가 된다(`is_inert`인 hub는 만료 작업조차 돌지 않는다). -- **guard는 절대 재생성하지 않는다.** relaunch 예산은 pane의 token으로 키를 잡는데, 그것이 exit마다 - relaunch로 답하는 plugin을 묶는 유일한 상한이다. reload마다 새 allowance를 발급하면 그 상한에 - 영영 닿지 않는다 — `take_over`가 spent budget을 그대로 두는 것과 같은 근거다. -- **relaunch hold는 그것을 쥐고 있던 자식과 함께 죽는다** — 교체든 정지든. 후계자는 **hub에 아직 - 남아 있는 pane만** 건네받는다(`start_host`가 `titles`로 걸러낸다). 그대로 두면 슬롯이 아무도 - 이행할 수 없는 9일 창을 끝까지 앉아 있는다. -- **plugin을 재시작하면 그 plugin이 진행 중이던 것은 사라진다.** 상태가 그 프로세스 안에 살기 - 때문이다 — `nightcrow-recovery`의 `panes: HashMap`은 메모리뿐이다. host가 대신 경고할 수 없다: - **살아 있는** pane에 대한 대기는 plugin 안에만 있고 host의 `pending`에는 없다. 그래서 이 손실의 - 범위를 좁히는 것이 `spec_changed`의 진짜 값이다. -- **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의 - 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. -- **reload와 프로젝트 열기의 경합은 Catalog의 façade transaction이 막는다.** 테이블 교체와 "알려줄 - 저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다 - (`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 - 둘 사이로 빠져 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. - -**답은 물어본 클라이언트에게만 간다** — reload가 하는 일은 다른 클라이언트 화면에 아무것도 -드러나지 않으므로, 전부에게 알리면 자기가 하지도 않았고 볼 수도 없는 일에 대한 알림이 된다. -브라우저에도 화면 변화가 없어 **toast가 피드백 전부**다. 문구는 서버가 만든다 -(`ReloadReport::summary`) — 같은 reload에 대해 TUI notice와 브라우저 toast가 다른 말을 하지 -않도록. **닿지 못한 저장소는 보고에 드러낸다**: 큐가 가득 찬 hub는 요청을 받지 못하는데, 막고 -기다리면 그 하나 때문에 나머지가 전부 밀리므로 기다리지 않고 `ReloadReport::unreachable`로 세어 -`(1 was too busy to be told)`로 덧붙인다. +`config.toml`을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가 작업 중이던 것까지. 그래서 **두 테이블만 다시 읽는다.** 무엇이 즉시 닿고 무엇이 안 닿는지는 "그 값을 이미 무엇에 썼는가"가 정한다. + +- **`[[plugin]]` — 열려 있는 모든 프로젝트에 즉시.** plugin은 pane이 아니라 자식 프로세스라 교체 비용이 세션에 없다. hub별로 diff한다(`terminal/hub_reload.rs`): 새로 원하게 된 것을 띄우고, 아닌 것을 멈추고, **`command`/`args`/`env`가 바뀐 것만** 프로세스를 갈아치운다. `allowed_resume_flags`·`watch_on_signal`만 바뀌면 살아 있는 자식을 건드리지 않는데, 그 둘은 판정마다 이쪽에서 읽는 값이고 plugin은 몇 시간짜리 대기 중일 수 있기 때문이다. +- **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 **딱 한 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 (`catalog/config_tables.rs`) 그 뒤 runtime reconcile이 띄우는 hub가 새 목록을 받는다. +- **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`. + +**전송 계층에 독립적이다.** `session.rs`와 같은 자리에 같은 이유로 둔다 — 브라우저는 `POST /api/reload`, attach한 TUI는 `ClientMessage::ReloadConfig`로 닿고, 둘이 **같은 상태 변경**에 착지해야 한다. 여기서 인증하지 않는 것도 `session.rs`와 같다(누가 물어볼 수 있는지는 각 전송이 정한다). 요청은 **아무것도 실어 나르지 않는다** — 파일 자체가 요청이다. 내용을 실어 보내게 하면 클라이언트가 지어낸 설정으로 세션을 재구성할 수 있다. + +**절반만 적용되지 않는다.** 파일 전체를 파싱·검증한 뒤에야 아무것이든 건드린다. **파일이 사라진 경우는 거부한다** — 시작 시에는 "아직 설정 없음"이 정상이지만 reload 시점에는 실수이고, 기본값으로 읽으면 파일을 지우고 reload하는 것이 모든 plugin을 조용히 멈추는 경로가 된다. `--exec` pane은 파일에 없으므로 Catalog가 따로 기억해 다시 병합한다(`config::merge_startup_commands`). + +**hub에서 무엇이 plugin을 원하는지는 그 hub의 opt-in으로 판정한다** — 새 파일의 것이 아니다. 편집으로 추가된 `[[startup_command]]`는 이미 뜬 hub에 pane이 없으니 그것이 가리키는 plugin을 띄우면 영영 아무것도 받을 수 없는 자식이 된다. 반대로 **살아 있는 pane을 보고 있는 plugin은 아무것도 그것을 지명하지 않아도 유지한다**: 살아 있는 agent 터미널을 조용히 감시 해제하는 쪽이 더 나쁘다. 멈추라는 뜻은 `enabled = false`이고 그건 따른다. + +- **pane의 opt-in은 host가 없어도 기록한다**(`hub_plugins.rs`의 `intended`). 이것이 세션 중간에 plugin을 켰을 때 그것이 꺼져 있는 동안 만들어진 pane에 닿게 하는 유일한 경로다. 그 자체로는 아무 권한도 주지 않는다: pane에 실제로 작용하는 것은 `owners`뿐이다. reload로 멈춘 plugin은 pane을 놓아주되 opt-in은 남기므로 **끄고 다시 켜면 처음 켜는 것과 같은 자리에 착지한다**. +- **후계자가 뜨지 못하면 그 pane들도 놓아준다**(`Plugins::abandon`). 교체는 멈춘 plugin이 살아 있는 pane을 계속 붙잡는 유일한 경우인데 그 근거는 곧 후계자가 온다는 것뿐이다. spawn이 실패하면 host 없는 이름이 pane을 소유한 채 남고, 그 pane이 다음에 끝날 때 아무도 부탁할 수 없는 9일짜리 hold가 된다(`is_inert`인 hub는 만료 작업조차 돌지 않는다). +- **guard는 절대 재생성하지 않는다.** relaunch 예산은 pane의 token으로 키를 잡는데, 그것이 exit마다 relaunch로 답하는 plugin을 묶는 유일한 상한이다. reload마다 새 allowance를 발급하면 그 상한에 영영 닿지 않는다 — `take_over`가 spent budget을 그대로 두는 것과 같은 근거다. +- **relaunch hold는 그것을 쥐고 있던 자식과 함께 죽는다** — 교체든 정지든. 후계자는 **hub에 아직 남아 있는 pane만** 건네받는다(`start_host`가 `titles`로 걸러낸다). 그대로 두면 슬롯이 아무도 이행할 수 없는 9일 창을 끝까지 앉아 있는다. +- **plugin을 재시작하면 그 plugin이 진행 중이던 것은 사라진다.** 상태가 그 프로세스 안에 살기 때문이다 — `nightcrow-recovery`의 `panes: HashMap`은 메모리뿐이다. host가 대신 경고할 수 없다: **살아 있는** pane에 대한 대기는 plugin 안에만 있고 host의 `pending`에는 없다. 그래서 이 손실의 범위를 좁히는 것이 `spec_changed`의 진짜 값이다. +- **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. +- **reload와 프로젝트 열기의 경합은 Catalog의 façade transaction이 막는다.** 테이블 교체와 "알려줄 저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다 (`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 둘 사이로 빠져 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. + +**답은 물어본 클라이언트에게만 간다** — reload가 하는 일은 다른 클라이언트 화면에 아무것도 드러나지 않으므로, 전부에게 알리면 자기가 하지도 않았고 볼 수도 없는 일에 대한 알림이 된다. 브라우저에도 화면 변화가 없어 **toast가 피드백 전부**다. 문구는 서버가 만든다 (`ReloadReport::summary`) — 같은 reload에 대해 TUI notice와 브라우저 toast가 다른 말을 하지 않도록. **닿지 못한 저장소는 보고에 드러낸다**: 큐가 가득 찬 hub는 요청을 받지 못하는데, 막고 기다리면 그 하나 때문에 나머지가 전부 밀리므로 기다리지 않고 `ReloadReport::unreachable`로 세어 `(1 was too busy to be told)`로 덧붙인다. ## Worker Thread Lifecycle (의도된 비대칭) -완료 후 한 번 답하는 백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 -먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, **호출 지점이 hot -path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다.** 리뷰 시 이 비대칭을 -깨뜨리지 말 것. - -- **Hot path (UI 틱 안)**: `launch_commit_log_worker`는 이전 `JoinHandle`을 join 없이 drop한다. 매 - prefetch마다 5 ms를 기다리면 스크롤이 jank해진다. worker 본체는 `tx.send` 1회 후 종료하므로 - 누적되지 않고, 받는 쪽(`page_rx`)을 먼저 drop했기 때문에 그 send는 즉시 실패한다. **timed-join을 - 여기 추가하지 말 것.** -- **Quiescent moment (Drop, repo switch, reply drain 직후)**: `cancel_commit_log_page_fetch`, - `poll_commit_log_page_fetch`의 reply drain 분기, `Drop` impl은 모두 `try_timed_join`(~5 ms)을 - 쓴다. 사용자가 클릭한 시점이거나 worker가 이미 마지막 syscall에 도달한 시점이라 UX 손실 없이 OS - 스레드를 즉시 회수한다. - -`try_timed_join`은 `src/platform/threading.rs`에 공유 helper로 두고 snapshot/commit-log/PTY 세 -곳에서 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 고른다. - -`GitLoadWorker`는 예외적으로 프로젝트 수명 동안 살아 있는 conflated worker다. 아직 시작하지 않은 -요청을 lane별 한 슬롯에 덮어써 10만 번의 연속 선택도 큐나 스레드를 10만 개 만들지 않는다. 따라서 -reply receiver drop만으로는 요청을 기다리는 `Condvar`를 깨울 수 없어 `Drop`이 stop flag를 세우고 -깨운 뒤 5ms 동안 완료를 기다린다. 실행 중인 libgit2 호출은 강제 중단하지 않으며 제한 안에 끝나지 -않은 handle은 detach한다. 대신 thread 수명 전체에 process-wide permit을 먼저 발급해 열린 프로젝트 -10개와 멈춘 libgit2 호출 8개를 합한 18개를 실제 thread/FD hard bound로 둔다. 별도의 공정한 FIFO -admission이 process/동일-repo libgit2 동시 호출을 8/1로 제한한다. 취소된 ticket은 큐에서 빠지고, -같은 repo 때문에 막힌 ticket은 unrelated repo의 eligible ticket을 가로막지 않는다. 늦은 reply는 -`(repo, generation)` guard가 버린다. 스레드 생성 실패는 pending request를 유지한 채 다음 submit 또는 -reply poll에서 재시도하되 16 ms부터 두 배씩 늘려 최대 1초까지 기다린다. 첫 실패는 경고하고 같은 실패가 -이어지면 경고도 30초에 한 번으로 제한하며, 생성에 성공하면 지연과 경고 제한을 모두 초기화한다. 예상하지 -못한 worker 종료는 완료된 handle을 회수한 뒤 같은 방식으로 재시작한다. 개별 git load panic은 cache를 -버리고 해당 request에 일반화된 error reply를 보내므로 worker와 이후 request는 계속 진행한다. +완료 후 한 번 답하는 백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, **호출 지점이 hot path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다.** 리뷰 시 이 비대칭을 깨뜨리지 말 것. + +- **Hot path (UI 틱 안)**: `launch_commit_log_worker`는 이전 `JoinHandle`을 join 없이 drop한다. 매 prefetch마다 5 ms를 기다리면 스크롤이 jank해진다. worker 본체는 `tx.send` 1회 후 종료하므로 누적되지 않고, 받는 쪽(`page_rx`)을 먼저 drop했기 때문에 그 send는 즉시 실패한다. **timed-join을 여기 추가하지 말 것.** +- **Quiescent moment (Drop, repo switch, reply drain 직후)**: `cancel_commit_log_page_fetch`, `poll_commit_log_page_fetch`의 reply drain 분기, `Drop` impl은 모두 `try_timed_join`(~5 ms)을 쓴다. 사용자가 클릭한 시점이거나 worker가 이미 마지막 syscall에 도달한 시점이라 UX 손실 없이 OS 스레드를 즉시 회수한다. + +`try_timed_join`은 `src/platform/threading.rs`에 공유 helper로 두고 snapshot/commit-log/PTY 세 곳에서 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 고른다. + +`GitLoadWorker`는 예외적으로 프로젝트 수명 동안 살아 있는 conflated worker다. 아직 시작하지 않은 요청을 lane별 한 슬롯에 덮어써 10만 번의 연속 선택도 큐나 스레드를 10만 개 만들지 않는다. 따라서 reply receiver drop만으로는 요청을 기다리는 `Condvar`를 깨울 수 없어 `Drop`이 stop flag를 세우고 깨운 뒤 5ms 동안 완료를 기다린다. 실행 중인 libgit2 호출은 강제 중단하지 않으며 제한 안에 끝나지 않은 handle은 detach한다. 대신 thread 수명 전체에 process-wide permit을 먼저 발급해 열린 프로젝트 10개와 멈춘 libgit2 호출 8개를 합한 18개를 실제 thread/FD hard bound로 둔다. 별도의 공정한 FIFO admission이 process/동일-repo libgit2 동시 호출을 8/1로 제한한다. 취소된 ticket은 큐에서 빠지고, 같은 repo 때문에 막힌 ticket은 unrelated repo의 eligible ticket을 가로막지 않는다. 늦은 reply는 `(repo, generation)` guard가 버린다. 스레드 생성 실패는 pending request를 유지한 채 다음 submit 또는 reply poll에서 재시도하되 16 ms부터 두 배씩 늘려 최대 1초까지 기다린다. 첫 실패는 경고하고 같은 실패가 이어지면 경고도 30초에 한 번으로 제한하며, 생성에 성공하면 지연과 경고 제한을 모두 초기화한다. 예상하지 못한 worker 종료는 완료된 handle을 회수한 뒤 같은 방식으로 재시작한다. 개별 git load panic은 cache를 버리고 해당 request에 일반화된 error reply를 보내므로 worker와 이후 request는 계속 진행한다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/terminal.md b/docs/architecture/terminal.md index 9dd9b834..b4954f99 100644 --- a/docs/architecture/terminal.md +++ b/docs/architecture/terminal.md @@ -1,114 +1,35 @@ # Terminal Panel -하단 터미널 패널의 레이아웃(여러 pane 동시 렌더), pane당 VT 에뮬레이션, 그리고 스크롤·마우스 -입력이 어느 pane의 어느 프로그램에게 어떤 모양으로 전달되는지를 다룬다. 관통하는 원칙 하나: -**청구하지 않은 pane에는 한 바이트도 보내지 않는다** — 프로그램이 스스로 켠 모드만이 무엇을 -보낼지 정한다. +하단 터미널 패널의 레이아웃(여러 pane 동시 렌더), pane당 VT 에뮬레이션, 그리고 스크롤·마우스 입력이 어느 pane의 어느 프로그램에게 어떤 모양으로 전달되는지를 다룬다. 관통하는 원칙 하나: **청구하지 않은 pane에는 한 바이트도 보내지 않는다** — 프로그램이 스스로 켠 모드만이 무엇을 보낼지 정한다. ## Split-View Terminal Panel -하단 패널은 현재 *visible window* 안의 모든 pane을 탭 전환 없이 한꺼번에 그린다. 창 밖으로 -스크롤된 pane의 PTY도 백그라운드에서 계속 돈다. - -- **Visible window**: `TerminalState.visible_start`/`active`가 - `[visible_start, visible_start + max_visible)` 인덱스 범위를 정의한다. `max_visible()`은 - `TerminalFullscreen` 상태가 결정한다: `Off` → `max_visible_normal`(4), `Grid` → - `max_visible_fullscreen`(8), `Zoom` → 1. `TerminalState::sync_visible_window`(순수 함수 - `runtime::terminal::visible_range`가 뒷받침)가 이 범위를 항상 `active`를 포함하도록 re-clamp하되, - 재중심화가 아니라 **최소한만** 민다. `active`나 pane 개수를 바꾸는 모든 것 뒤에 호출해야 한다 — - `create_pane_with`, `switch_pane`, `swap_active_with`, `cycle_focus_forward/backward`, pane - close/exit clamp, 세션 복원이 모두 그렇게 한다. **`active`를 바꾸는 새 지점을 짝 없이 추가하는 - 것은 버그다.** -- **Pane reorder (swap)**: `TerminalState::swap_active_with(idx)`가 정렬된 `panes` Vec에서 active - pane과 `idx`의 pane을 교환하고 `active = idx`로 두어 포커스가 옮겨간 pane을 따라간다. Vec 순서만 - 바뀐다 — pane별 상태(파서, 스크롤, 크기, prompt 버퍼, backend PTY)는 전부 안정적인 `PaneId`로 - 키를 잡으므로 재정렬이 그것들을 건드리지 않는다. pane 순서는 영속되지 않고(PTY는 살아 있는 - 프로세스라 재시작 시 `startup_commands`로 다시 만들어진다) swap은 세션 한정이며, 저장된 - `active_pane` 인덱스는 `active`가 함께 갱신되므로 일관을 유지한다. ` s`가 두 번째 - follow-up 상태(`App::awaiting_swap_target`, `prefix_armed`와 상호 배타)를 arm하고, 다음 digit은 - focus-jump digit과 **같은** layout-aware 매핑(`resolve_prefix_action`)으로 풀린다. arming은 - ` w`와 같은 terminal-focus 스코프를 공유하고(없으면 swap의 첫 피연산자인 active pane이 - 구별되지 않게 그려진다) 추가로 pane이 둘 이상이어야 한다. 아니면 키는 소비만 되고 armed 힌트 - 행도 `s: swap pane`을 숨긴다. -- **Layout-aware jump keys**: leader digit 행은 레이아웃에 따라 매핑이 바뀐다. split view에서 - `input::prefix_action`은 `1`=list, `2`=diff, `3`..`9`,`0`=pane `0`..`7`. 터미널이 body를 - 채우면(`fills_body()`) 상단 뷰어가 숨으므로 `main::resolve_prefix_action`이 - `input::prefix_action_fullscreen`으로 갈아끼워 `1`..`8` → pane `0`..`7`로 자연수 번호를 매긴다 - (`9`/`0` 제거, 비-jump 키는 그대로). fullscreen에서 list/diff로 돌아가는 jump 키는 없다 — 유일한 - 출구는 fullscreen을 순환시키는 ` f`다. 탭 바(`render_tab_bar`)가 활성 매핑을 legend에 - 그대로 반영한다. bare F키 행은 **별개 축**이다: `F1`..`F10`이 프로젝트 탭을 고르고 의도적으로 - layout-aware가 아니어서, 한 F키가 모든 뷰에서 한 프로젝트에 닿는다. pane legend가 F키가 아니라 - leader 화음을 부르는 이유가 그것이다. -- **Fullscreen cycle**: 터미널 포커스에서 ` f`가 `App::toggle_terminal_fullscreen`으로 - `TerminalFullscreen::{Off, Grid, Zoom}`을 `Off → Grid → Zoom → Off`로 순환시킨다. `Grid`와 - `Zoom` 모두 상단 뷰어를 숨기고 body 전체를 터미널에 넘긴다(`fills_body()`). `Zoom`은 전용 렌더 - 경로가 필요 없다 — `max_visible()`을 1로 깎으면 공유 grid 경로가 active pane 하나만 그린다(단일 - pane이므로 보더 없음). `Grid`가 pane 하나만 보일 상황에서는 둘이 구별되지 않으므로 사이클이 - `Zoom`을 건너뛴다. 그 판정의 단일 출처는 `TerminalState::zoom_distinct_from_grid` - (`max_visible_fullscreen.min(panes.len()) > 1`)이고 토글·pane close 정규화·힌트 텍스트가 공유한다. - body를 채우는 상태로 들어가면 포커스가 터미널로 가고 경쟁하는 diff/list fullscreen이 해제된다. - 마지막 pane을 닫으면 `Off`로 리셋. 영속화는 저장 시 `Zoom`을 `Grid`로 접는다(세션은 bool 하나). -- **Grid layout**: `ui::terminal_tab::split_pane_areas`가 1 pane은 전체 폭, 2는 좌우(좁으면 상하), - 3은 2칼럼 행 + 전체 폭 나머지, 4는 2x2, 5–6은 3칼럼, 7은 4행+3행으로 배치한다. 단일 pane은 - **보더 없는 전용 코드 경로**를 탄다 — 터미널 출력을 복사할 때(마우스 캡처 중 bypass - modifier+드래그, 또는 `[mouse]` 끄고 맨 드래그) 잘못 딸려오는 `│`가 절대 없어야 하고, 이것이 - 압도적으로 흔한 경우라 회귀시키면 안 된다. -- **Sizing invariant**: `ui::terminal_tab::visible_pane_cells`가 pane Rect의 단일 출처다. `render`가 - 매 프레임 여기서 그리고, `ui::terminal_content_areas` → `main_loop`의 `resize_visible_panes`도 - 같은 함수를 읽으므로 pane의 backend PTY + 에뮬레이터 크기가 그려진 셀과 정확히 일치한다. **새 - 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** 원격 backend에서는 - 요청 직후 에뮬레이터를 낙관적으로 바꾸지 않고 세션의 `Resized` 확인을 따라간다. 원하는 크기와 - 확인된 크기가 다르면 재요청하므로 빠른 연속 resize의 마지막 셀 크기로 수렴한다. -- **Input/scroll scope는 그대로**: 키보드 입력, paste, prompt 로깅, 터미널 스크롤 - (`TerminalState::active_pane_rows`가 페이지 크기)은 여러 pane이 그려져도 active pane만 겨냥한다. -- **Accent는 "active pane"이 아니라 진짜 포커스를 뜻한다**: accent 색은 앱 전역에서 "이 영역이 - 지금 키보드 포커스를 갖는다"에만 예약돼 있다(`focused_border_style`, `FileList`/`DiffViewer`가 - 동일하게 사용). active pane의 셀 보더/탭은 `Focus::Terminal`이 함께 참일 때만 accent를 받고, - 아니면 비활성 pane과 픽셀 단위로 동일하게 렌더된다(plain `Color::DarkGray`/`Color::Gray`, bold - 없음, 밝은 대체색 없음). +하단 패널은 현재 *visible window* 안의 모든 pane을 탭 전환 없이 한꺼번에 그린다. 창 밖으로 스크롤된 pane의 PTY도 백그라운드에서 계속 돈다. + +- **Visible window**: `TerminalState.visible_start`/`active`가 `[visible_start, visible_start + max_visible)` 인덱스 범위를 정의한다. `max_visible()`은 `TerminalFullscreen` 상태가 결정한다: `Off` → `max_visible_normal`(4), `Grid` → `max_visible_fullscreen`(8), `Zoom` → 1. `TerminalState::sync_visible_window`(순수 함수 `runtime::terminal::visible_range`가 뒷받침)가 이 범위를 항상 `active`를 포함하도록 re-clamp하되, 재중심화가 아니라 **최소한만** 민다. `active`나 pane 개수를 바꾸는 모든 것 뒤에 호출해야 한다 — `create_pane_with`, `switch_pane`, `swap_active_with`, `cycle_focus_forward/backward`, pane close/exit clamp, 세션 복원이 모두 그렇게 한다. **`active`를 바꾸는 새 지점을 짝 없이 추가하는 것은 버그다.** +- **Pane reorder (swap)**: `TerminalState::swap_active_with(idx)`가 정렬된 `panes` Vec에서 active pane과 `idx`의 pane을 교환하고 `active = idx`로 두어 포커스가 옮겨간 pane을 따라간다. Vec 순서만 바뀐다 — pane별 상태(파서, 스크롤, 크기, prompt 버퍼, backend PTY)는 전부 안정적인 `PaneId`로 키를 잡으므로 재정렬이 그것들을 건드리지 않는다. pane 순서는 영속되지 않고(PTY는 살아 있는 프로세스라 재시작 시 `startup_commands`로 다시 만들어진다) swap은 세션 한정이며, 저장된 `active_pane` 인덱스는 `active`가 함께 갱신되므로 일관을 유지한다. ` s`가 두 번째 follow-up 상태(`App::awaiting_swap_target`, `prefix_armed`와 상호 배타)를 arm하고, 다음 digit은 focus-jump digit과 **같은** layout-aware 매핑(`resolve_prefix_action`)으로 풀린다. arming은 ` w`와 같은 terminal-focus 스코프를 공유하고(없으면 swap의 첫 피연산자인 active pane이 구별되지 않게 그려진다) 추가로 pane이 둘 이상이어야 한다. 아니면 키는 소비만 되고 armed 힌트 행도 `s: swap pane`을 숨긴다. +- **Layout-aware jump keys**: leader digit 행은 레이아웃에 따라 매핑이 바뀐다. split view에서 `input::prefix_action`은 `1`=list, `2`=diff, `3`..`9`,`0`=pane `0`..`7`. 터미널이 body를 채우면(`fills_body()`) 상단 뷰어가 숨으므로 `main::resolve_prefix_action`이 `input::prefix_action_fullscreen`으로 갈아끼워 `1`..`8` → pane `0`..`7`로 자연수 번호를 매긴다 (`9`/`0` 제거, 비-jump 키는 그대로). fullscreen에서 list/diff로 돌아가는 jump 키는 없다 — 유일한 출구는 fullscreen을 순환시키는 ` f`다. 탭 바(`render_tab_bar`)가 활성 매핑을 legend에 그대로 반영한다. bare F키 행은 **별개 축**이다: `F1`..`F10`이 프로젝트 탭을 고르고 의도적으로 layout-aware가 아니어서, 한 F키가 모든 뷰에서 한 프로젝트에 닿는다. pane legend가 F키가 아니라 leader 화음을 부르는 이유가 그것이다. +- **Fullscreen cycle**: 터미널 포커스에서 ` f`가 `App::toggle_terminal_fullscreen`으로 `TerminalFullscreen::{Off, Grid, Zoom}`을 `Off → Grid → Zoom → Off`로 순환시킨다. `Grid`와 `Zoom` 모두 상단 뷰어를 숨기고 body 전체를 터미널에 넘긴다(`fills_body()`). `Zoom`은 전용 렌더 경로가 필요 없다 — `max_visible()`을 1로 깎으면 공유 grid 경로가 active pane 하나만 그린다(단일 pane이므로 보더 없음). `Grid`가 pane 하나만 보일 상황에서는 둘이 구별되지 않으므로 사이클이 `Zoom`을 건너뛴다. 그 판정의 단일 출처는 `TerminalState::zoom_distinct_from_grid` (`max_visible_fullscreen.min(panes.len()) > 1`)이고 토글·pane close 정규화·힌트 텍스트가 공유한다. body를 채우는 상태로 들어가면 포커스가 터미널로 가고 경쟁하는 diff/list fullscreen이 해제된다. 마지막 pane을 닫으면 `Off`로 리셋. 영속화는 저장 시 `Zoom`을 `Grid`로 접는다(세션은 bool 하나). +- **Grid layout**: `ui::terminal_tab::split_pane_areas`가 1 pane은 전체 폭, 2는 좌우(좁으면 상하), 3은 2칼럼 행 + 전체 폭 나머지, 4는 2x2, 5–6은 3칼럼, 7은 4행+3행으로 배치한다. 단일 pane은 **보더 없는 전용 코드 경로**를 탄다 — 터미널 출력을 복사할 때(마우스 캡처 중 bypass modifier+드래그, 또는 `[mouse]` 끄고 맨 드래그) 잘못 딸려오는 `│`가 절대 없어야 하고, 이것이 압도적으로 흔한 경우라 회귀시키면 안 된다. +- **Sizing invariant**: `ui::terminal_tab::visible_pane_cells`가 pane Rect의 단일 출처다. `render`가 매 프레임 여기서 그리고, `ui::terminal_content_areas` → `main_loop`의 `resize_visible_panes`도 같은 함수를 읽으므로 pane의 backend PTY + 에뮬레이터 크기가 그려진 셀과 정확히 일치한다. **새 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** 원격 backend에서는 요청 직후 에뮬레이터를 낙관적으로 바꾸지 않고 세션의 `Resized` 확인을 따라간다. 원하는 크기와 확인된 크기가 다르면 재요청하므로 빠른 연속 resize의 마지막 셀 크기로 수렴한다. +- **Input/scroll scope는 그대로**: 키보드 입력, paste, prompt 로깅, 터미널 스크롤 (`TerminalState::active_pane_rows`가 페이지 크기)은 여러 pane이 그려져도 active pane만 겨냥한다. +- **Accent는 "active pane"이 아니라 진짜 포커스를 뜻한다**: accent 색은 앱 전역에서 "이 영역이 지금 키보드 포커스를 갖는다"에만 예약돼 있다(`focused_border_style`, `FileList`/`DiffViewer`가 동일하게 사용). active pane의 셀 보더/탭은 `Focus::Terminal`이 함께 참일 때만 accent를 받고, 아니면 비활성 pane과 픽셀 단위로 동일하게 렌더된다(plain `Color::DarkGray`/`Color::Gray`, bold 없음, 밝은 대체색 없음). ## Terminal Emulation Layer -`runtime::emulator::PaneEmulator`가 pane당 하나씩 alacritty_terminal의 `Term` + ANSI `Processor`를 -감싸고, 렌더러는 `ScreenView`/`CellView`로만 화면을 조회한다. alacritty 타입은 이 모듈 밖으로 -노출되지 않으므로 에뮬레이터 교체·업그레이드의 영향 범위가 이 파일 하나로 국소화된다 — -그리드를 ANSI 바이트로 되돌리는 `screen_snapshot`(`snapshot.rs`)이 이 모듈 안에 있는 이유도 -그것이다. 그 스냅샷이 무엇에 쓰이는지는 [session.md](session.md#스크롤백과-재접속). - -원래는 vt100 크레이트를 썼으나 alacritty_terminal 0.26으로 교체했다. 근거: vt100은 (1) 스크롤백 -underflow panic, (2) 스크롤 offset 초과 panic, (3) wide char(한글 등)가 마지막 컬럼에 걸린 채 화면이 -축소되면 이후 ED 처리에서 index out of bounds panic(upstream issue #28, 미수정)으로 세 차례 크래시를 -냈고 업스트림 유지보수가 정체 상태다. alacritty_terminal은 Alacritty/Zed에서 실전 검증된 활발한 -프로젝트로 리사이즈 시 reflow까지 지원한다. 대안으로 검토한 avt(asciinema)는 바이트 입력·OSC 타이틀 -통지가 없고, tui-term/shpool_vt100은 내부가 vt100이라 같은 버그를 공유해 제외했다. 단, alacritty의 -최소 그리드는 1행 x 2열(`MIN_COLUMNS`)이라 `PaneEmulator`가 요청 크기를 이 최소값으로 클램프한다 — -1열 그리드는 wide char reflow가 무한 루프에 빠진다. - -- **OSC title capture**: `Term`이 OSC 0/2 타이틀을 `Event::Title`로 통지하면 `PaneEmulator::process`가 - 수집해 반환하고, `TerminalState::poll`이 `PaneInfo.title`에 반영해 탭 바에서 노출한다. - claude/vim/ssh처럼 자체 타이틀을 갱신하는 프로그램은 자동으로 적절한 라벨이 붙고, 타이틀을 보내지 - 않는 셸은 기본 라벨을 유지한다. -- **프로젝트 attention은 클라이언트 로컬이다**: 숨은 프로젝트의 pane이 BEL을 울리거나, OSC 제목이 - 최소 세 번·600ms 이상 연속으로 바뀐 뒤 800ms 동안 안정되거나, pane 프로세스가 종료되면 - `TerminalState.unread_attention`을 세운다. 제목 조건은 Codex 같은 animated title을 provider 이름이나 - spinner 글리프를 하드코딩하지 않고 관측하는 좁은 heuristic이다. 단순 출력 idle은 완료의 증거가 - 아니므로 쓰지 않는다. 활성 프로젝트는 매 poll 뒤 attention과 진행 중 title 관측을 지운다 — 이미 - 화면에 보인 활동이 사용자가 다른 탭으로 간 뒤 새 알림으로 되살아나면 안 된다. daemon/session에 - 저장하지 않는 이유는 attach한 TUI와 브라우저가 서로의 읽음 상태를 지우면 안 되기 때문이다. -- **Terminal query replies**: DSR/DA처럼 내부 프로그램이 터미널에 묻는 쿼리에 대해 에뮬레이터가 - 생성한 응답(`Event::PtyWrite`)을 `TerminalState::poll`이 해당 pane의 PTY로 되돌려준다. vt100 - 시절에는 응답이 불가능해 쿼리가 무시됐다. +`runtime::emulator::PaneEmulator`가 pane당 하나씩 alacritty_terminal의 `Term` + ANSI `Processor`를 감싸고, 렌더러는 `ScreenView`/`CellView`로만 화면을 조회한다. alacritty 타입은 이 모듈 밖으로 노출되지 않으므로 에뮬레이터 교체·업그레이드의 영향 범위가 이 파일 하나로 국소화된다 — 그리드를 ANSI 바이트로 되돌리는 `screen_snapshot`(`snapshot.rs`)이 이 모듈 안에 있는 이유도 그것이다. 그 스냅샷이 무엇에 쓰이는지는 [session.md](session.md#스크롤백과-재접속). + +원래는 vt100 크레이트를 썼으나 alacritty_terminal 0.26으로 교체했다. 근거: vt100은 (1) 스크롤백 underflow panic, (2) 스크롤 offset 초과 panic, (3) wide char(한글 등)가 마지막 컬럼에 걸린 채 화면이 축소되면 이후 ED 처리에서 index out of bounds panic(upstream issue #28, 미수정)으로 세 차례 크래시를 냈고 업스트림 유지보수가 정체 상태다. alacritty_terminal은 Alacritty/Zed에서 실전 검증된 활발한 프로젝트로 리사이즈 시 reflow까지 지원한다. 대안으로 검토한 avt(asciinema)는 바이트 입력·OSC 타이틀 통지가 없고, tui-term/shpool_vt100은 내부가 vt100이라 같은 버그를 공유해 제외했다. 단, alacritty의 최소 그리드는 1행 x 2열(`MIN_COLUMNS`)이라 `PaneEmulator`가 요청 크기를 이 최소값으로 클램프한다 — 1열 그리드는 wide char reflow가 무한 루프에 빠진다. + +- **OSC title capture**: `Term`이 OSC 0/2 타이틀을 `Event::Title`로 통지하면 `PaneEmulator::process`가 수집해 반환하고, `TerminalState::poll`이 `PaneInfo.title`에 반영해 탭 바에서 노출한다. claude/vim/ssh처럼 자체 타이틀을 갱신하는 프로그램은 자동으로 적절한 라벨이 붙고, 타이틀을 보내지 않는 셸은 기본 라벨을 유지한다. +- **프로젝트 attention은 클라이언트 로컬이다**: 숨은 프로젝트의 pane이 BEL을 울리거나, OSC 제목이 최소 세 번·600ms 이상 연속으로 바뀐 뒤 800ms 동안 안정되거나, pane 프로세스가 종료되면 `TerminalState.unread_attention`을 세운다. 제목 조건은 Codex 같은 animated title을 provider 이름이나 spinner 글리프를 하드코딩하지 않고 관측하는 좁은 heuristic이다. 단순 출력 idle은 완료의 증거가 아니므로 쓰지 않는다. 활성 프로젝트는 매 poll 뒤 attention과 진행 중 title 관측을 지운다 — 이미 화면에 보인 활동이 사용자가 다른 탭으로 간 뒤 새 알림으로 되살아나면 안 된다. daemon/session에 저장하지 않는 이유는 attach한 TUI와 브라우저가 서로의 읽음 상태를 지우면 안 되기 때문이다. +- **Terminal query replies**: DSR/DA처럼 내부 프로그램이 터미널에 묻는 쿼리에 대해 에뮬레이터가 생성한 응답(`Event::PtyWrite`)을 `TerminalState::poll`이 해당 pane의 PTY로 되돌려준다. vt100 시절에는 응답이 불가능해 쿼리가 무시됐다. ## Scroll Routing -터미널 스크롤 키(`Shift+↑/↓`, `Shift+PgUp/PgDn`)는 항상 에뮬레이터 스크롤백을 움직이는 게 아니라 -**pane 안의 프로그램이 기대하는 입력으로 변환**되어 전달된다. 자기 뷰포트를 직접 소유하는 -프로그램은 트랜스크립트를 에뮬레이터 그리드가 아니라 자기 메모리에 두므로 그리드를 스크롤해도 -드러날 내용이 없다. 특히 alacritty는 alternate screen 그리드를 스크롤백 0으로 만든다 -(`Grid::new(lines, cols, 0)`). +터미널 스크롤 키(`Shift+↑/↓`, `Shift+PgUp/PgDn`)는 항상 에뮬레이터 스크롤백을 움직이는 게 아니라 **pane 안의 프로그램이 기대하는 입력으로 변환**되어 전달된다. 자기 뷰포트를 직접 소유하는 프로그램은 트랜스크립트를 에뮬레이터 그리드가 아니라 자기 메모리에 두므로 그리드를 스크롤해도 드러날 내용이 없다. 특히 alacritty는 alternate screen 그리드를 스크롤백 0으로 만든다 (`Grid::new(lines, cols, 0)`). -어디로 보낼지는 프로그램이 스스로 켠 모드가 알려준다. `PaneEmulator::scroll_sink()`가 판정하고 -`TerminalState::scroll_active`가 실행한다. +어디로 보낼지는 프로그램이 스스로 켠 모드가 알려준다. `PaneEmulator::scroll_sink()`가 판정하고 `TerminalState::scroll_active`가 실행한다. | `ScrollSink` | 조건 | 전달할 입력 | 해당 프로그램 | |---|---|---|---| @@ -116,68 +37,26 @@ underflow panic, (2) 스크롤 offset 초과 panic, (3) wide char(한글 등)가 | `ArrowKeys` | `ALT_SCREEN` + `ALTERNATE_SCROLL` | 방향키 (xterm alternateScroll) | `less`, `man` | | `Scrollback` | 그 외 (기본값) | 없음 — 에뮬레이터 뷰를 스크롤 | bash, zsh | -우선순위는 xterm과 같다. 휠을 요청한 프로그램은 alternate screen에서도 휠을 받는다. `MOUSE_MODE`만 -있고 `SGR_MOUSE`가 없으면 legacy X10 인코딩을 기대하는 것인데, 223열을 넘기지 못하는 그 인코딩을 -위해 두 번째 인코더를 두는 대신 `Scrollback`으로 떨어뜨린다. +우선순위는 xterm과 같다. 휠을 요청한 프로그램은 alternate screen에서도 휠을 받는다. `MOUSE_MODE`만 있고 `SGR_MOUSE`가 없으면 legacy X10 인코딩을 기대하는 것인데, 223열을 넘기지 못하는 그 인코딩을 위해 두 번째 인코더를 두는 대신 `Scrollback`으로 떨어뜨린다. -`Scrollback`이 기본값이어야 하는 이유는 안전 문제다. bash/zsh는 바인딩되지 않은 이스케이프 -시퀀스를 받으면 BEL을 울리고 `;2A` 같은 잔여 문자를 프롬프트에 그대로 삽입한다. 따라서 스크롤을 -청구하지 않은 pane에는 **한 바이트도 보내지 않는다**. +`Scrollback`이 기본값이어야 하는 이유는 안전 문제다. bash/zsh는 바인딩되지 않은 이스케이프 시퀀스를 받으면 BEL을 울리고 `;2A` 같은 잔여 문자를 프롬프트에 그대로 삽입한다. 따라서 스크롤을 청구하지 않은 pane에는 **한 바이트도 보내지 않는다**. -합성한 입력은 `send_input`이 아니라 `write_pty`로 나간다. 사용자가 누른 키가 아니므로 스크롤 -위치를 초기화하거나 prompt log에 남으면 안 된다 — 에뮬레이터의 쿼리 응답이 `send_input`을 우회하는 -것과 같은 이유다. +합성한 입력은 `send_input`이 아니라 `write_pty`로 나간다. 사용자가 누른 키가 아니므로 스크롤 위치를 초기화하거나 prompt log에 남으면 안 된다 — 에뮬레이터의 쿼리 응답이 `send_input`을 우회하는 것과 같은 이유다. ## Mouse Routing -`[mouse] enabled`(기본 on)일 때 crossterm `EnableMouseCapture`로 마우스를 캡처한다. 캡처는 화면 -전체 단위라 pane별로 쪼갤 수 없으므로, 바깥 터미널의 네이티브 텍스트 선택은 modifier+드래그 -오버라이드로 우회한다(bypass modifier는 터미널마다 다르다 — xterm 계열 Shift, iTerm2 Option, -macOS Terminal.app Fn/Option). 끄면 마우스는 바깥 터미널 소유로 돌아간다. - -캡처된 이벤트는 `main::handle_mouse`가 `ui::pane_at`으로 hit-test한다. `pane_at`은 렌더링과 동일한 -`terminal_content_areas` 기하를 재사용하므로 화면과 판정이 어긋날 수 없다. pane content 셀 -밖(상단 패널, 보더, 탭 바)에 떨어진 이벤트는 버린다. - -- **상단 패널 클릭**: pane content 밖의 press는 `ui::upper_panel_at`(draw와 동일한 split 기하)으로 - 다시 판정해, 리스트/diff 영역이면 focus만 옮긴다(F1/F2와 동일). fullscreen에서는 판정하지 - 않는다 — body를 채운 패널이 이미 focus를 갖는다. -- **클릭**: press가 클릭된 pane을 활성화하고 focus를 터미널로 옮긴다 — jump key와 동일. - press/release는 `TerminalState::click_pane`이 pane-local 1-based 좌표의 SGR(1006) 버튼 리포트로 - 변환하되, `PaneEmulator::wants_mouse_buttons`(`MOUSE_MODE`+`SGR_MOUSE`)를 켠 프로그램에만 보낸다. - 스크롤과 같은 침묵 규칙이며, 클릭은 스크롤백 폴백이 없으므로 미청구 클릭은 조용히 버려진다. -- **release 짝짓기**: release는 포인터 아래 pane이 아니라 **press를 받은 pane**으로 간다 - (`App::pending_mouse_press`, single slot). 드래그 리포트를 포워딩하지 않으므로 프로그램은 포인터 - 이탈을 스스로 알 수 없다 — press를 본 프로그램은 release도 봐야 하고, 포인터가 우연히 머문 - pane이 press 없는 release를 받아서는 안 된다. release 좌표는 press pane의 현재 rect로 클램프하고, - 그 pane이 닫혔거나 숨겨졌으면 release를 버린다. -- **휠**: 활성 pane이 아니라 **포인터 아래 pane**을 `scroll_pane`으로 스크롤한다. sink 판정은 위 - 표와 동일하되 `MouseWheel` sink의 리포트 좌표는 실제 포인터 셀을 그대로 전달한다(키보드 스크롤만 - pane 중앙 폴백 — 포인터가 없으므로). 비활성 pane의 `Scrollback` sink에는 per-frame - `sync_scroll`(활성 pane 전용)이 닿지 않으므로 `scroll_pane`이 오프셋을 즉시 직접 적용한다. -- **탭 바 클릭**: `ui::tab_click_at` → `terminal_tab::tab_target_at`. 탭/`+N` 마커 세그먼트와 클릭 - 타겟은 렌더러와 공유하는 `tab_segments` 빌더가 단일 소스다. 탭 클릭은 jump key와 동일하게 - `switch_pane`을 타고, `+N` hidden 마커는 그쪽 방향의 가장 가까운 hidden pane으로 점프해 - `sync_visible_window`가 창을 한 칸만 슬라이드한다. -- **힌트 바 클릭**: 최하단 행의 press는 `ui::hint_click_at`이 렌더러와 동일한 힌트 텍스트 - (`normal_hint_literal`/`prefix_armed_hint_text` 공유)를 display width로 세그먼트화해 판정한다. - 이산 명령(` t/w/f/l/b/o`, armed row의 follow-up, 포커스된 패널이 프리픽스 없이 받는 - `v`/`s`/`/`/`n`/`shift+n`)만 클릭 가능하고, 연속 내비게이션·digit legend·`esc`는 비클릭이다. - 대상은 `segment_click`의 명시적 키 목록이다 — 힌트 텍스트만으로는 명령과 내비게이션을 구분할 수 - 없으므로, `hint_text`에 명령을 추가해도 이 목록에 넣기 전까지는 조용히 비클릭으로 남는다. bare `: leader` 라벨도 클릭 가능하며 leader - chord keypress를 합성해 프리픽스를 arm한다 — "leader 클릭 → 명령 클릭"의 마우스-only 플로우가 - 이어진다. **`q: detach`는 오클릭 한 번으로 TUI가 떨어져 나가지 않도록 의도적으로 제외**했다. 디스패치는 - 라벨이 가리키는 키 입력을 그대로 합성해 `handle_key`로 보낸다 — 클릭과 실제 키가 모든 가드와 - 코드 경로를 공유하므로 클릭이 키와 다른 동작을 할 수 없다. `r: redraw`의 `KeyOutcome` 전파를 - 위해 `handle_mouse`도 `KeyOutcome`을 반환한다. 클릭 가능한 세그먼트는 `hint_spans`가 - `key: description` 라벨 전체를 REVERSED로 렌더링해 어포던스를 표시하고, 판정을 `segment_click`과 - 공유하므로 반전 범위와 hit-test가 어긋날 수 없다. `[mouse] enabled = false`면 반전도 꺼진다. -- **swap 모드 클릭**: ` s` 대기 중의 좌클릭은 digit follow-up과 동일하게 **swap 대상 - 지명**으로 해석한다 — pane 또는 그 탭을 클릭하면 활성 pane과 교환하고, pane을 지명하지 않는 - press는 consume+disarm. 이 분기가 없으면 클릭이 swap 상태를 방치한 채 활성 pane만 바꿔 다음 - digit이 엉뚱한 pane을 교환한다. -- **드래그/모션**: 포워딩하지 않는다. 내부 프로그램의 자체 텍스트 선택은 지원 범위 밖이고, 텍스트 - 선택은 바깥 터미널의 bypass modifier+드래그가 담당한다. +`[mouse] enabled`(기본 on)일 때 crossterm `EnableMouseCapture`로 마우스를 캡처한다. 캡처는 화면 전체 단위라 pane별로 쪼갤 수 없으므로, 바깥 터미널의 네이티브 텍스트 선택은 modifier+드래그 오버라이드로 우회한다(bypass modifier는 터미널마다 다르다 — xterm 계열 Shift, iTerm2 Option, macOS Terminal.app Fn/Option). 끄면 마우스는 바깥 터미널 소유로 돌아간다. + +캡처된 이벤트는 `main::handle_mouse`가 `ui::pane_at`으로 hit-test한다. `pane_at`은 렌더링과 동일한 `terminal_content_areas` 기하를 재사용하므로 화면과 판정이 어긋날 수 없다. pane content 셀 밖(상단 패널, 보더, 탭 바)에 떨어진 이벤트는 버린다. + +- **상단 패널 클릭**: pane content 밖의 press는 `ui::upper_panel_at`(draw와 동일한 split 기하)으로 다시 판정해, 리스트/diff 영역이면 focus만 옮긴다(F1/F2와 동일). fullscreen에서는 판정하지 않는다 — body를 채운 패널이 이미 focus를 갖는다. +- **클릭**: press가 클릭된 pane을 활성화하고 focus를 터미널로 옮긴다 — jump key와 동일. press/release는 `TerminalState::click_pane`이 pane-local 1-based 좌표의 SGR(1006) 버튼 리포트로 변환하되, `PaneEmulator::wants_mouse_buttons`(`MOUSE_MODE`+`SGR_MOUSE`)를 켠 프로그램에만 보낸다. 스크롤과 같은 침묵 규칙이며, 클릭은 스크롤백 폴백이 없으므로 미청구 클릭은 조용히 버려진다. +- **release 짝짓기**: release는 포인터 아래 pane이 아니라 **press를 받은 pane**으로 간다 (`App::pending_mouse_press`, single slot). 드래그 리포트를 포워딩하지 않으므로 프로그램은 포인터 이탈을 스스로 알 수 없다 — press를 본 프로그램은 release도 봐야 하고, 포인터가 우연히 머문 pane이 press 없는 release를 받아서는 안 된다. release 좌표는 press pane의 현재 rect로 클램프하고, 그 pane이 닫혔거나 숨겨졌으면 release를 버린다. +- **휠**: 활성 pane이 아니라 **포인터 아래 pane**을 `scroll_pane`으로 스크롤한다. sink 판정은 위 표와 동일하되 `MouseWheel` sink의 리포트 좌표는 실제 포인터 셀을 그대로 전달한다(키보드 스크롤만 pane 중앙 폴백 — 포인터가 없으므로). 비활성 pane의 `Scrollback` sink에는 per-frame `sync_scroll`(활성 pane 전용)이 닿지 않으므로 `scroll_pane`이 오프셋을 즉시 직접 적용한다. +- **탭 바 클릭**: `ui::tab_click_at` → `terminal_tab::tab_target_at`. 탭/`+N` 마커 세그먼트와 클릭 타겟은 렌더러와 공유하는 `tab_segments` 빌더가 단일 소스다. 탭 클릭은 jump key와 동일하게 `switch_pane`을 타고, `+N` hidden 마커는 그쪽 방향의 가장 가까운 hidden pane으로 점프해 `sync_visible_window`가 창을 한 칸만 슬라이드한다. +- **힌트 바 클릭**: 최하단 행의 press는 `ui::hint_click_at`이 렌더러와 동일한 힌트 텍스트 (`normal_hint_literal`/`prefix_armed_hint_text` 공유)를 display width로 세그먼트화해 판정한다. 이산 명령(` t/w/f/l/b/o`, armed row의 follow-up, 포커스된 패널이 프리픽스 없이 받는 `v`/`s`/`/`/`n`/`shift+n`)만 클릭 가능하고, 연속 내비게이션·digit legend·`esc`는 비클릭이다. 대상은 `segment_click`의 명시적 키 목록이다 — 힌트 텍스트만으로는 명령과 내비게이션을 구분할 수 없으므로, `hint_text`에 명령을 추가해도 이 목록에 넣기 전까지는 조용히 비클릭으로 남는다. bare `: leader` 라벨도 클릭 가능하며 leader chord keypress를 합성해 프리픽스를 arm한다 — "leader 클릭 → 명령 클릭"의 마우스-only 플로우가 이어진다. **`q: detach`는 오클릭 한 번으로 TUI가 떨어져 나가지 않도록 의도적으로 제외**했다. 디스패치는 라벨이 가리키는 키 입력을 그대로 합성해 `handle_key`로 보낸다 — 클릭과 실제 키가 모든 가드와 코드 경로를 공유하므로 클릭이 키와 다른 동작을 할 수 없다. `r: redraw`의 `KeyOutcome` 전파를 위해 `handle_mouse`도 `KeyOutcome`을 반환한다. 클릭 가능한 세그먼트는 `hint_spans`가 `key: description` 라벨 전체를 REVERSED로 렌더링해 어포던스를 표시하고, 판정을 `segment_click`과 공유하므로 반전 범위와 hit-test가 어긋날 수 없다. `[mouse] enabled = false`면 반전도 꺼진다. +- **swap 모드 클릭**: ` s` 대기 중의 좌클릭은 digit follow-up과 동일하게 **swap 대상 지명**으로 해석한다 — pane 또는 그 탭을 클릭하면 활성 pane과 교환하고, pane을 지명하지 않는 press는 consume+disarm. 이 분기가 없으면 클릭이 swap 상태를 방치한 채 활성 pane만 바꿔 다음 digit이 엉뚱한 pane을 교환한다. +- **드래그/모션**: 포워딩하지 않는다. 내부 프로그램의 자체 텍스트 선택은 지원 범위 밖이고, 텍스트 선택은 바깥 터미널의 bypass modifier+드래그가 담당한다. 합성 버튼 리포트도 스크롤과 같은 이유로 `send_input`이 아니라 `write_pty`로 나간다. diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index c7bd8423..8a52134e 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -1,163 +1,62 @@ # UI & Input -키가 어디로 가는지(leader 모델), 한 프로세스가 저장소 N개를 탭으로 여는 경계(`Workspace`/`App`), -그리고 하단 크롬 두 행 중 위쪽인 notice row를 다룬다. 세 주제는 한 제약을 공유한다 — **1순위 -사용자는 pane에서 LLM CLI를 굴리는 cockpit 사용자**이므로, 앱이 가로채는 키와 화면에 생겼다 -사라지는 행을 최소로 유지한다. +키가 어디로 가는지(leader 모델), 한 프로세스가 저장소 N개를 탭으로 여는 경계(`Workspace`/`App`), 그리고 하단 크롬 두 행 중 위쪽인 notice row를 다룬다. 세 주제는 한 제약을 공유한다 — **1순위 사용자는 pane에서 LLM CLI를 굴리는 cockpit 사용자**이므로, 앱이 가로채는 키와 화면에 생겼다 사라지는 행을 최소로 유지한다. ## Keyboard Routing -라우팅은 leader(prefix) 모델을 따른다. `Ctrl+W`/`Ctrl+L` 같은 프롬프트 편집 Ctrl 키가 nightcrow에 -가로채이지 않고 PTY로 통과해야 하므로, 앱 전역 명령은 leader 뒤에 한 키를 눌러야만 실행된다. - -- **Leader (prefix)**: 기본값 `Ctrl+F`, `[input] leader`로 변경 가능(`config.rs::parse_leader`가 - `ctrl+`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면 - `App.interaction.prefix_armed`가 켜지고 다음 키 한 개가 앱 명령(`input::prefix_action`)으로 - 해석된다. **타임아웃은 없다** — 해제 - 경로는 셋뿐이다: 매핑된 키 → Action 실행 후 해제, 미매핑 키 → 소비 후 해제, `Esc`/`Ctrl+C` → - 취소. ` `는 terminal focus에서 leader를 `encode_key`로 리터럴 PTY 전송한다. -- **prefix 매핑**: `t`=NewPane, `w`=ClosePane(terminal focus 한정 — unfocus 시 active pane이 다른 - pane과 동일하게 그려져 닫힐 대상이 보이지 않으므로, 키는 소비하되 no-op이고 힌트 바에도 노출하지 - 않는다), `s`=pane swap 대기 arm(같은 terminal-focus 스코프 + pane 2개 이상 — - [terminal.md](terminal.md#split-view-terminal-panel) 참고), `c`=CancelRecovery(대기 중인 것이 있을 - 때만 힌트에 노출), `l`=ToggleLogView, `b`=ToggleTreeView, `f`=ToggleFullscreen, - `o`=OpenProject(저장소를 새 프로젝트 탭으로 — 제자리 교체 명령은 없다), `x`=CloseProject, - `p`=CycleTheme, `r`=Redraw, `q`=Quit. 숫자는 지금 body가 보여주는 것을 지시한다: `1`=FocusList, - `2`=FocusDiff, `3`–`9`,`0`=pane 0–7 포커스 이동(`0`은 digit이 9까지뿐이라 8번째 pane). pane 포커스 - 이동은 탭 전환이 아니라 어떤 pane이 active인지만 바꾼다 — grid는 이동 전후로 계속 여러 pane을 - 동시에 그린다. -- **No-prefix 예약키**: `F1`–`F10`(프로젝트 탭 1–10 — layout에 따라 바뀌지 않는 유일한 점프 축), - `Shift+←/→`(focus cycle — terminal focus에서는 active pane을 앞/뒤로 이동), - `Shift+↑/↓`·`Shift+PgUp/PgDn`(터미널 스크롤, active pane 기준 — - [terminal.md](terminal.md#scroll-routing) 참고)는 leader 없이 항상 앱이 먼저 처리한다. modifier - 또는 F-key라서 프롬프트 텍스트와 혼동되지 않는다. -- **Upper panel focused**: 나머지는 로컬 네비게이션(`j`/`k`, `/`, `v`, `n`/`N`, `Enter`, `Esc`, - 화살표, `PgUp`/`PgDn`)이다. `j`/`k`는 upper-pane handler 내부에서 vim navigation으로 변환되며, - `map_key`는 plain character로 통과시켜 terminal focus에서 PTY로 그대로 전달되게 한다. -- **Lower panel focused (terminal)**: leader/예약키가 아닌 모든 키는 active backend의 stdin으로 - 직접 통과한다(`encode_key`가 화살표/F-key/제어문자를 VT100 시퀀스로 인코딩). 단독 - `Ctrl+T/W/L/O/P/Q` 등은 control byte로 PTY에 간다(리더 `Ctrl+F`만 arm하고 통과하지 않는다). bare - F키는 앱이 가로채므로 pane 안 프로그램(htop, mc 등)의 F키 메뉴는 동작하지 않는다 — 수정자를 붙인 - `Ctrl+F1`, `Shift+F5` 등은 통과한다. -- **Paste**: `Event::Paste`는 `dispatch_paste`로 가고, terminal focus면 ESC·NUL을 걷어낸 뒤 pane - 프로그램이 DECSET 2004를 켰을 때만 `ESC[200~ … ESC[201~`으로 감싼다(`input::paste`). - **Windows에는 paste input record가 없어** 문자 단위 key burst로 들어오므로 5 ms 간극까지 이어 - 훑어(최대 8192건 / 250 ms) synthetic `Event::Paste`로 바꾼다(`input::burst`). 콘솔이 붙여넣기를 - 점진적으로 넣기 때문에 zero-wait poll은 단어 중간에서 끊긴다. 판정은 - 좁다 — 수정자 없는 문자/Enter press만이고 **Enter 뒤에 문자가 오거나** 문자 16개 초과일 때만 - paste. Enter는 줄을 넘기므로 그 뒤에 남은 문자가 곧 Enter가 제출하지 않은 내용이라는 증거다. - 줄 끝의 Enter는 뒤가 비어 있으니 타이핑으로 남고, 그래서 느린 frame에 키가 밀려 한 burst로 - 들어와도 제출이 붙여넣기로 바뀌지 않는다. 타이핑을 삼키는 오탐이 더 비싸기 때문이고, 어긋나면 - 순서 그대로 평소 dispatch로 되돌린다. -- overlay(repo input/search)가 활성이면 leader dispatch가 금지되고 overlay가 키를 소유한다. armed - 중 overlay가 열리는 경로면 prefix를 취소한다. repo 다이얼로그는 `Workspace` 소유라 - `main::dispatch_key`가 per-project 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기 - 때문. -- **프로젝트가 없을 때**: `main::handle_empty_key`가 leader arming과 `o`/`q`만 해석하고 나머지는 - 버린다. ` `는 여기서 액션 테이블로 넘어가지 않는다 — 기본 leader가 `ctrl+f`라 follow-up이 - `f`에 매칭돼 fullscreen이 토글될 수 있기 때문. -- 좌/우 패널 타이틀에는 현재 포커스 단축키(` 1` / ` 2`)가 노출된다. `ui::jump_legend`가 - leader label과 digit을 **공백으로** 이어 붙인다 — `^F1`로 붙여 쓰면 Ctrl+F1로 읽히고, 그 조합은 - 앱이 가로채지 않고 PTY로 통과시키는 별개 키라 오해를 만든다. +라우팅은 leader(prefix) 모델을 따른다. `Ctrl+W`/`Ctrl+L` 같은 프롬프트 편집 Ctrl 키가 nightcrow에 가로채이지 않고 PTY로 통과해야 하므로, 앱 전역 명령은 leader 뒤에 한 키를 눌러야만 실행된다. + +- **Leader (prefix)**: 기본값 `Ctrl+F`, `[input] leader`로 변경 가능(`config.rs::parse_leader`가 `ctrl+`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면 `App.interaction.prefix_armed`가 켜지고 다음 키 한 개가 앱 명령(`input::prefix_action`)으로 해석된다. **타임아웃은 없다** — 해제 경로는 셋뿐이다: 매핑된 키 → Action 실행 후 해제, 미매핑 키 → 소비 후 해제, `Esc`/`Ctrl+C` → 취소. ` `는 terminal focus에서 leader를 `encode_key`로 리터럴 PTY 전송한다. +- **prefix 매핑**: `t`=NewPane, `w`=ClosePane(terminal focus 한정 — unfocus 시 active pane이 다른 pane과 동일하게 그려져 닫힐 대상이 보이지 않으므로, 키는 소비하되 no-op이고 힌트 바에도 노출하지 않는다), `s`=pane swap 대기 arm(같은 terminal-focus 스코프 + pane 2개 이상 — [terminal.md](terminal.md#split-view-terminal-panel) 참고), `c`=CancelRecovery(대기 중인 것이 있을 때만 힌트에 노출), `l`=ToggleLogView, `b`=ToggleTreeView, `f`=ToggleFullscreen, `o`=OpenProject(저장소를 새 프로젝트 탭으로 — 제자리 교체 명령은 없다), `x`=CloseProject, `p`=CycleTheme, `r`=Redraw, `q`=Quit. 숫자는 지금 body가 보여주는 것을 지시한다: `1`=FocusList, `2`=FocusDiff, `3`–`9`,`0`=pane 0–7 포커스 이동(`0`은 digit이 9까지뿐이라 8번째 pane). pane 포커스 이동은 탭 전환이 아니라 어떤 pane이 active인지만 바꾼다 — grid는 이동 전후로 계속 여러 pane을 동시에 그린다. +- **No-prefix 예약키**: `F1`–`F10`(프로젝트 탭 1–10 — layout에 따라 바뀌지 않는 유일한 점프 축), `Shift+←/→`(focus cycle — terminal focus에서는 active pane을 앞/뒤로 이동), `Shift+↑/↓`·`Shift+PgUp/PgDn`(터미널 스크롤, active pane 기준 — [terminal.md](terminal.md#scroll-routing) 참고)는 leader 없이 항상 앱이 먼저 처리한다. modifier 또는 F-key라서 프롬프트 텍스트와 혼동되지 않는다. +- **Upper panel focused**: 나머지는 로컬 네비게이션(`j`/`k`, `/`, `v`, `n`/`N`, `Enter`, `Esc`, 화살표, `PgUp`/`PgDn`)이다. `j`/`k`는 upper-pane handler 내부에서 vim navigation으로 변환되며, `map_key`는 plain character로 통과시켜 terminal focus에서 PTY로 그대로 전달되게 한다. +- **Lower panel focused (terminal)**: leader/예약키가 아닌 모든 키는 active backend의 stdin으로 직접 통과한다(`encode_key`가 화살표/F-key/제어문자를 VT100 시퀀스로 인코딩). 단독 `Ctrl+T/W/L/O/P/Q` 등은 control byte로 PTY에 간다(리더 `Ctrl+F`만 arm하고 통과하지 않는다). bare F키는 앱이 가로채므로 pane 안 프로그램(htop, mc 등)의 F키 메뉴는 동작하지 않는다 — 수정자를 붙인 `Ctrl+F1`, `Shift+F5` 등은 통과한다. +- **Paste**: `Event::Paste`는 `dispatch_paste`로 가고, terminal focus면 ESC·NUL을 걷어낸 뒤 pane 프로그램이 DECSET 2004를 켰을 때만 `ESC[200~ … ESC[201~`으로 감싼다(`input::paste`). **Windows에는 paste input record가 없어** 문자 단위 key burst로 들어오므로 5 ms 간극까지 이어 훑어(최대 8192건 / 250 ms) synthetic `Event::Paste`로 바꾼다(`input::burst`). 콘솔이 붙여넣기를 점진적으로 넣기 때문에 zero-wait poll은 단어 중간에서 끊긴다. 판정은 좁다 — 수정자 없는 문자/Enter press만이고 **Enter 뒤에 문자가 오거나** 문자 16개 초과일 때만 paste. Enter는 줄을 넘기므로 그 뒤에 남은 문자가 곧 Enter가 제출하지 않은 내용이라는 증거다. 줄 끝의 Enter는 뒤가 비어 있으니 타이핑으로 남고, 그래서 느린 frame에 키가 밀려 한 burst로 들어와도 제출이 붙여넣기로 바뀌지 않는다. 타이핑을 삼키는 오탐이 더 비싸기 때문이고, 어긋나면 순서 그대로 평소 dispatch로 되돌린다. +- overlay(repo input/search)가 활성이면 leader dispatch가 금지되고 overlay가 키를 소유한다. armed 중 overlay가 열리는 경로면 prefix를 취소한다. repo 다이얼로그는 `Workspace` 소유라 `main::dispatch_key`가 per-project 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기 때문. +- **프로젝트가 없을 때**: `main::handle_empty_key`가 leader arming과 `o`/`q`만 해석하고 나머지는 버린다. ` `는 여기서 액션 테이블로 넘어가지 않는다 — 기본 leader가 `ctrl+f`라 follow-up이 `f`에 매칭돼 fullscreen이 토글될 수 있기 때문. +- 좌/우 패널 타이틀에는 현재 포커스 단축키(` 1` / ` 2`)가 노출된다. `ui::jump_legend`가 leader label과 digit을 **공백으로** 이어 붙인다 — `^F1`로 붙여 쓰면 Ctrl+F1로 읽히고, 그 조합은 앱이 가로채지 않고 PTY로 통과시키는 별개 키라 오해를 만든다. ## Project Boundary (`Workspace` / `App`) 한 프로세스가 저장소 N개(최대 `MAX_PROJECTS` = 10, F1~F10 키 공간과 일치)를 탭으로 연다. -- `App` = 저장소 하나의 상태 전부. 터미널 pane도 `App`에 있으므로 프로젝트마다 자기 PTY 집합과 - cwd를 갖는다. -- `Workspace` = `Vec` + 활성 인덱스. 탭 전환은 프로젝트 작업 상태를 건드리지 않으며, - 클라이언트 로컬 attention만 읽음 처리한다. 목록은 **비어 있을 수 있다** — 인자 없는 실행이 그 - 상태이고, 마지막 탭을 닫아도 그리로 돌아온다. 그래서 `active()`가 `Option`이다. -- 숨은 프로젝트의 terminal attention은 F-key와 탭 이름 사이의 기존 공백을 `•` 한 셀로 바꿔 - 집계한다. 밝음/어두움만 1초마다 바꿔 점멸하므로 표시·해제 또는 점멸 중에도 텍스트 폭과 mouse hit - box는 움직이지 않는다. 프로젝트가 활성화되어 한 frame의 terminal event를 소비하면 그 - 클라이언트에서만 읽음 처리한다. - -저장소를 "교체"하는 경로는 없다. 탭을 닫으면 `App`이 drop되면서 `SnapshotChannel`이 worker를 -join하고 `TerminalState`가 자식 프로세스를 정리하므로, 손으로 유지하는 초기화 목록이 존재하지 -않는다. 제자리 교체는 pane을 살려두는 탓에 탭 라벨과 셸의 작업 디렉토리가 어긋나기도 했다. - -**프로세스 레벨 상태** — 저장소 열기 다이얼로그(`repo_input`)는 `Workspace`에 있다. 프로젝트가 -없을 때도 동작해야 하는데, 그때가 바로 이 다이얼로그가 유일한 행동이기 때문이다. 반면 -`handle_key`는 여전히 `&mut App` 하나만 받는다 — `dispatch_key`가 워크스페이스 레벨 경우를 먼저 -해소하므로, 프로젝트별 입력 경로 전체가 프로젝트 하나만 아는 채로 유지된다. 워크스페이스 수준 -의도는 `KeyOutcome::Project(ProjectRequest)`로 반환하고 `main_loop`이 실행한다. +- `App` = 저장소 하나의 상태 전부. 터미널 pane도 `App`에 있으므로 프로젝트마다 자기 PTY 집합과 cwd를 갖는다. +- `Workspace` = `Vec` + 활성 인덱스. 탭 전환은 프로젝트 작업 상태를 건드리지 않으며, 클라이언트 로컬 attention만 읽음 처리한다. 목록은 **비어 있을 수 있다** — 인자 없는 실행이 그 상태이고, 마지막 탭을 닫아도 그리로 돌아온다. 그래서 `active()`가 `Option`이다. +- 숨은 프로젝트의 terminal attention은 F-key와 탭 이름 사이의 기존 공백을 `•` 한 셀로 바꿔 집계한다. 밝음/어두움만 1초마다 바꿔 점멸하므로 표시·해제 또는 점멸 중에도 텍스트 폭과 mouse hit box는 움직이지 않는다. 프로젝트가 활성화되어 한 frame의 terminal event를 소비하면 그 클라이언트에서만 읽음 처리한다. + +저장소를 "교체"하는 경로는 없다. 탭을 닫으면 `App`이 drop되면서 `SnapshotChannel`이 worker를 join하고 `TerminalState`가 자식 프로세스를 정리하므로, 손으로 유지하는 초기화 목록이 존재하지 않는다. 제자리 교체는 pane을 살려두는 탓에 탭 라벨과 셸의 작업 디렉토리가 어긋나기도 했다. + +**프로세스 레벨 상태** — 저장소 열기 다이얼로그(`repo_input`)는 `Workspace`에 있다. 프로젝트가 없을 때도 동작해야 하는데, 그때가 바로 이 다이얼로그가 유일한 행동이기 때문이다. 반면 `handle_key`는 여전히 `&mut App` 하나만 받는다 — `dispatch_key`가 워크스페이스 레벨 경우를 먼저 해소하므로, 프로젝트별 입력 경로 전체가 프로젝트 하나만 아는 채로 유지된다. 워크스페이스 수준 의도는 `KeyOutcome::Project(ProjectRequest)`로 반환하고 `main_loop`이 실행한다. ### 경로 완성 (`workspace/path_complete.rs`) -다이얼로그의 `Tab`이 여기로 간다. 셸을 PTY로 띄우지 않는 이유와 대안 비교는 -[decisions.md](../decisions.md)에 있다 — 요약하면 Windows에 readline 대응 프리미티브가 없어서 -네이티브 완성기가 어차피 필요하다. 규칙은 무상태 하나다: **확장할 게 있으면 확장하고, 없으면 -후보를 보여준다.** 단 fragment가 비어 있으면(구분자로 끝나는 상태) 확장과 동시에 목록도 낸다 — -그때의 `Tab`은 "여기 뭐가 있냐"는 질문이라 조용한 확장은 답이 아니다. Tab 한 번에 `read_dir` 한 -단계만 읽고 디렉터리만 후보로 삼는다. - -- **사용자가 입력한 텍스트는 다시 쓰지 않는다.** `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 - 완성된 컴포넌트만 이어붙인다 — `~/x`를 `/Users/me/x`로 바꿔 써넣으면 사용자가 타이핑한 적 없는 - 경로가 화면에 남는다. -- `git::tree::read_children`(`ViewMode::Tree`용)을 쓰지 **않는다**. 그쪽은 `git2::Repository`가 - 필수이고 repo-relative 경로만 받으며 워크트리 밖 경로와 심볼릭 링크를 거부하는데, 피커는 어떤 - repo에도 속하지 않는 경로를 돌아다녀야 하고 프로젝트가 0개일 때도 떠야 한다. 심볼릭 링크 정책도 - 반대다 — 트리는 따라가지 않지만(순환 방지) 피커는 따라간다(링크된 체크아웃이 실제 repo다). -- 후보는 hint 행에 표시한다(`repo_dialog::repo_dialog_hint_line`). 우선순위는 notice > 후보 > - legend — notice 행이 자유로울 때 적용하는 것과 같은 순서다. 플로팅 팝업을 쓰지 않은 이유는 - `src/ui/`에 오버레이 인프라가 없고(모든 surface가 레이아웃 행을 차지한다) 마우스 캡처가 기본 - on이라 `hit_test.rs`에 새 히트 영역이 필요해지기 때문이다. +다이얼로그의 `Tab`이 여기로 간다. 셸을 PTY로 띄우지 않는 이유와 대안 비교는 [decisions.md](../decisions.md)에 있다 — 요약하면 Windows에 readline 대응 프리미티브가 없어서 네이티브 완성기가 어차피 필요하다. 규칙은 무상태 하나다: **확장할 게 있으면 확장하고, 없으면 후보를 보여준다.** 단 fragment가 비어 있으면(구분자로 끝나는 상태) 확장과 동시에 목록도 낸다 — 그때의 `Tab`은 "여기 뭐가 있냐"는 질문이라 조용한 확장은 답이 아니다. Tab 한 번에 `read_dir` 한 단계만 읽고 디렉터리만 후보로 삼는다. + +- **사용자가 입력한 텍스트는 다시 쓰지 않는다.** `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 완성된 컴포넌트만 이어붙인다 — `~/x`를 `/Users/me/x`로 바꿔 써넣으면 사용자가 타이핑한 적 없는 경로가 화면에 남는다. +- `git::tree::read_children`(`ViewMode::Tree`용)을 쓰지 **않는다**. 그쪽은 `git2::Repository`가 필수이고 repo-relative 경로만 받으며 워크트리 밖 경로와 심볼릭 링크를 거부하는데, 피커는 어떤 repo에도 속하지 않는 경로를 돌아다녀야 하고 프로젝트가 0개일 때도 떠야 한다. 심볼릭 링크 정책도 반대다 — 트리는 따라가지 않지만(순환 방지) 피커는 따라간다(링크된 체크아웃이 실제 repo다). +- 후보는 hint 행에 표시한다(`repo_dialog::repo_dialog_hint_line`). 우선순위는 notice > 후보 > legend — notice 행이 자유로울 때 적용하는 것과 같은 순서다. 플로팅 팝업을 쓰지 않은 이유는 `src/ui/`에 오버레이 인프라가 없고(모든 surface가 레이아웃 행을 차지한다) 마우스 캡처가 기본 on이라 `hit_test.rs`에 새 히트 영역이 필요해지기 때문이다. ### 디렉터리 브라우저 (`workspace/path_tree.rs` + `ui/path_tree.rs`) -경로를 아는 경우(형제 체크아웃 — prefill이 노리는 케이스)는 타이핑이 빠르고 모르는 경우는 -브라우저가 낫다. 둘은 경쟁이 아니라 계층이다. - -- **진입은 `↓`**(또는 `↑`). printable 문자는 전부 합법 경로 문자라 쓸 수 없고, 필드의 수평 - 키(`→`/`End`=prefill 수락)는 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 있다. `Ctrl+T`는 - 접었다: `T` 니모닉이 ` t`와 겹치고, 다이얼로그의 다른 키가 전부 bare인데 Ctrl 화음만 튄다. -- **후보 목록이 떠 있을 때의 두 번째 `Tab`도 브라우저로 승격한다.** 그 상태의 Tab은 같은 목록을 - 다시 그리는 죽은 키였고, 평면 목록이 실패한 지점이 정확히 거기다. -- **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는 지점은 필드의 - `Enter` 한 곳뿐이다. 그래서 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` - 전용이며 `Enter`는 파일 열기다). -- **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래 깊은 row를 drain — - 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다. -- **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와 canonical - `PathBuf`를 따로 들고, 고른 경로는 `root_text` 기준으로 조립한다. `←`가 depth 0에서 루트를 한 - 단계 올릴 때만 예외 — `~`나 Windows 드라이브의 부모는 사용자 표기로 표현할 수 없으므로 절대 - 경로로 대체하되, 텍스트 수술을 믿지 않고 `canonicalize` 결과를 실제 부모와 대조해 검증한다. -- **body 전체를 쓴다**(위의 팝업 부재와 같은 이유). 다이얼로그가 이미 모든 키를 소유하므로 view - mode·fullscreen 분기보다 앞에서 body를 가로챈다. 마우스 클릭 선택은 범위 밖. 세션 저장도 하지 - 않는다: 필드가 활성 프로젝트 경로로 prefill되므로 "지난 위치"가 새 영속 상태 없이 따라온다. -- 브라우저를 열면 `prefilled`가 해제된다. 브라우저는 버퍼에 전체 경로를 쓰므로, 플래그가 살아 - 있으면 복귀 후 첫 타이핑이 방금 고른 경로를 지운다. - -입력 필드는 notice 행의 repo 헤더 자리에 그려진다(`repo_dialog::repo_input_line`) — 헤더는 떠나는 -repo를, 입력은 여는 repo를 말하는데 지금 결정 중인 것은 하나뿐이고, 행을 통째로 가지면 경로가 -legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint 행이 알린다 -(`repo_dialog::repo_dialog_hint_line`) — 다이얼로그가 평소 legend를 대체하므로 키를 알릴 다른 -자리가 없고, 거부 notice와 Tab 후보가 뜨면 잠시 legend를 덮는다(어느 쪽이든 편집 한 번에 -사라지므로 legend가 오래 가려지지 않는다). +경로를 아는 경우(형제 체크아웃 — prefill이 노리는 케이스)는 타이핑이 빠르고 모르는 경우는 브라우저가 낫다. 둘은 경쟁이 아니라 계층이다. + +- **진입은 `↓`**(또는 `↑`). printable 문자는 전부 합법 경로 문자라 쓸 수 없고, 필드의 수평 키(`→`/`End`=prefill 수락)는 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 있다. `Ctrl+T`는 접었다: `T` 니모닉이 ` t`와 겹치고, 다이얼로그의 다른 키가 전부 bare인데 Ctrl 화음만 튄다. +- **후보 목록이 떠 있을 때의 두 번째 `Tab`도 브라우저로 승격한다.** 그 상태의 Tab은 같은 목록을 다시 그리는 죽은 키였고, 평면 목록이 실패한 지점이 정확히 거기다. +- **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는 지점은 필드의 `Enter` 한 곳뿐이다. 그래서 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` 전용이며 `Enter`는 파일 열기다). +- **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래 깊은 row를 drain — 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다. +- **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와 canonical `PathBuf`를 따로 들고, 고른 경로는 `root_text` 기준으로 조립한다. `←`가 depth 0에서 루트를 한 단계 올릴 때만 예외 — `~`나 Windows 드라이브의 부모는 사용자 표기로 표현할 수 없으므로 절대 경로로 대체하되, 텍스트 수술을 믿지 않고 `canonicalize` 결과를 실제 부모와 대조해 검증한다. +- **body 전체를 쓴다**(위의 팝업 부재와 같은 이유). 다이얼로그가 이미 모든 키를 소유하므로 view mode·fullscreen 분기보다 앞에서 body를 가로챈다. 마우스 클릭 선택은 범위 밖. 세션 저장도 하지 않는다: 필드가 활성 프로젝트 경로로 prefill되므로 "지난 위치"가 새 영속 상태 없이 따라온다. +- 브라우저를 열면 `prefilled`가 해제된다. 브라우저는 버퍼에 전체 경로를 쓰므로, 플래그가 살아 있으면 복귀 후 첫 타이핑이 방금 고른 경로를 지운다. + +입력 필드는 notice 행의 repo 헤더 자리에 그려진다(`repo_dialog::repo_input_line`) — 헤더는 떠나는 repo를, 입력은 여는 repo를 말하는데 지금 결정 중인 것은 하나뿐이고, 행을 통째로 가지면 경로가 legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint 행이 알린다 (`repo_dialog::repo_dialog_hint_line`) — 다이얼로그가 평소 legend를 대체하므로 키를 알릴 다른 자리가 없고, 거부 notice와 Tab 후보가 뜨면 잠시 legend를 덮는다(어느 쪽이든 편집 한 번에 사라지므로 legend가 오래 가려지지 않는다). ### Polling · 세션 · 자원 -- **Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker, git-load worker와 - PTY reader가 계속 생산하므로), 스냅샷을 *적용*하는 것은 활성 프로젝트뿐이다. 배경 스냅샷은 - `pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. git-load 결과는 git I/O 없이 - generation을 확인하고 메모리 상태만 교체하므로 숨은 프로젝트도 즉시 비운다. 그래야 탭을 떠난 - 사이 끝난 이전 선택 결과가 큐에 남아 복귀 frame을 되돌리지 않는다. -- **중복 방지** — 다른 탭이 이미 연 저장소는 두 번 열지 않고 그 탭으로 포커스를 옮긴다. 같은 - workdir에 프로젝트 두 개는 스냅샷 worker가 중복으로 돌고 같은 session 파일에 쓴다. git 저장소가 - 아닌 경로는 canonicalize해서 철자 차이(`/w` vs `/w/`)가 이 검사를 빠져나가지 못하게 한다. -- **세션** — 열린 탭 목록, 활성 탭, 저장소별 뷰 상태가 모두 `~/.nightcrow/workspace.json` 한 파일에 - 들어간다. 저장소 안에는 아무것도 쓰지 않는다: 어떤 저장소도 "옆에 다른 셋이 열려 있었다"는 - 사실을 소유하지 않는다. 뷰 상태는 최근 사용한 50개 저장소까지 LRU로 유지한다. `--repo`가 주어지면 - 탭 목록은 복원하지 않는다 — 명시적 인자가 이긴다. 빈 목록도 기록한다: 탭을 다 닫고 종료하는 것이 - 다음 실행을 빈 화면으로 시작하는 방법이고, 기록을 건너뛰면 이전 탭이 되살아난다. -- **복원 시점** — 세션은 로드 즉시 적용한다. pane/focus/fullscreen은 어떤 데이터도 필요 없고, Log는 - commit log를, Tree는 디렉토리를 직접 읽는다. 유일한 예외가 Status 모드의 파일 선택인데, 변경 파일 - 목록이 필요해 `pending_selection`에 대기한다. 이 지연은 사용자 조작과 충돌할 수 없다 — 빈 - 목록에서는 선택할 파일이 없기 때문이다. -- **자원 (측정치, 2026-07-20)** — 저장소 10개(각 파일 30개, 그중 10개 dirty), 프로젝트당 pane 2개, - release 빌드: +- **Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker, git-load worker와 PTY reader가 계속 생산하므로), 스냅샷을 *적용*하는 것은 활성 프로젝트뿐이다. 배경 스냅샷은 `pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. git-load 결과는 git I/O 없이 generation을 확인하고 메모리 상태만 교체하므로 숨은 프로젝트도 즉시 비운다. 그래야 탭을 떠난 사이 끝난 이전 선택 결과가 큐에 남아 복귀 frame을 되돌리지 않는다. +- **중복 방지** — 다른 탭이 이미 연 저장소는 두 번 열지 않고 그 탭으로 포커스를 옮긴다. 같은 workdir에 프로젝트 두 개는 스냅샷 worker가 중복으로 돌고 같은 session 파일에 쓴다. git 저장소가 아닌 경로는 canonicalize해서 철자 차이(`/w` vs `/w/`)가 이 검사를 빠져나가지 못하게 한다. +- **세션** — 열린 탭 목록, 활성 탭, 저장소별 뷰 상태가 모두 `~/.nightcrow/workspace.json` 한 파일에 들어간다. 저장소 안에는 아무것도 쓰지 않는다: 어떤 저장소도 "옆에 다른 셋이 열려 있었다"는 사실을 소유하지 않는다. 뷰 상태는 최근 사용한 50개 저장소까지 LRU로 유지한다. `--repo`가 주어지면 탭 목록은 복원하지 않는다 — 명시적 인자가 이긴다. 빈 목록도 기록한다: 탭을 다 닫고 종료하는 것이 다음 실행을 빈 화면으로 시작하는 방법이고, 기록을 건너뛰면 이전 탭이 되살아난다. +- **복원 시점** — 세션은 로드 즉시 적용한다. pane/focus/fullscreen은 어떤 데이터도 필요 없고, Log는 commit log를, Tree는 디렉토리를 직접 읽는다. 유일한 예외가 Status 모드의 파일 선택인데, 변경 파일 목록이 필요해 `pending_selection`에 대기한다. 이 지연은 사용자 조작과 충돌할 수 없다 — 빈 목록에서는 선택할 파일이 없기 때문이다. +- **자원 (측정치, 2026-07-20)** — 저장소 10개(각 파일 30개, 그중 10개 dirty), 프로젝트당 pane 2개, release 빌드: | | 1 프로젝트 | 10 프로젝트 | |---|---|---| @@ -166,80 +65,30 @@ legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint | 자식 프로세스 | 1 | 19 | | 유휴 CPU | — | 20초에 0.47초 (~2.4%) | - 메모리는 프로젝트당 0.5MB 남짓만 늘어 사실상 문제가 아니고, 유휴 CPU도 낮다. 탭 전환은 인덱스 - 변경이라 실측 70ms 수준(대부분 렌더링). 주목할 것은 **스레드가 프로젝트당 7개로 선형 증가**한다는 - 점이다(snapshot worker, git-load worker, commit-log fetch, PTY당 reader/wait 쌍). 70개 자체는 문제가 아니지만 이를 - 막고 있는 것은 `MAX_PROJECTS`(10)와 pane 상한(8)이다. 상한을 올리자는 논의가 나오면 이 선형성을 - 근거로 재검토해야 한다. 표의 스레드 수는 git-load worker 추가분을 구조적으로 반영한 값이고, - 나머지 측정치는 pane 2개 기준이라 최악(10 × 8)은 재보지 않았다. -- **로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. 첫 `--repo`를, 그것도 - 없으면 작업 디렉토리를 고정 기준으로 삼는다. + 메모리는 프로젝트당 0.5MB 남짓만 늘어 사실상 문제가 아니고, 유휴 CPU도 낮다. 탭 전환은 인덱스 변경이라 실측 70ms 수준(대부분 렌더링). 주목할 것은 **스레드가 프로젝트당 7개로 선형 증가**한다는 점이다(snapshot worker, git-load worker, commit-log fetch, PTY당 reader/wait 쌍). 70개 자체는 문제가 아니지만 이를 막고 있는 것은 `MAX_PROJECTS`(10)와 pane 상한(8)이다. 상한을 올리자는 논의가 나오면 이 선형성을 근거로 재검토해야 한다. 표의 스레드 수는 git-load worker 추가분을 구조적으로 반영한 값이고, 나머지 측정치는 pane 2개 기준이라 최악(10 × 8)은 재보지 않았다. +- **로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. 첫 `--repo`를, 그것도 없으면 작업 디렉토리를 고정 기준으로 삼는다. ## Polling and dirty frames -The attached TUI still polls its input and runtime queues every 16 ms so PTY -output, snapshot results, tree watcher events, and log pages are noticed -promptly. Polling is not a frame clock: `application::redraw::RedrawState` -requests a draw only for a state-changing event, a terminal-size change, or a -visible attention/search-caret phase change. The first frame is always drawn; -an unchanged idle tick does no `Terminal::draw` call. Terminal polling reports -output, title, resize, pane lifecycle, recovery, delayed synchronized-update, -and settled-title activity so a redraw cannot depend only on keyboard input. -The status list also schedules the exact Fresh→Warm (5 seconds) and Warm→Cool -(`hot_window_secs`) boundaries for the active repository, so its fade remains -correct without a 60 FPS frame clock. - -` r` remains an explicit full repaint: it clears ratatui's front buffer -and marks the next loop dirty, covering terminal programs that left cells the -diff renderer cannot know about. Input events also request a frame before their -effects are observed, which keeps prefix/overlay/focus changes visible even -when a PTY echo is delayed. Resize events and direct size observations share -the same dirty path, so a missed crossterm resize notification cannot leave the -new geometry unpainted. - -The release measurement is intentionally opt-in because it starts a real shell: -`cargo test --release measure_dirty_redraw -- --ignored --nocapture`. It prints -before/after draw counts and `ratatui::Terminal` CPU timings for -idle, one-second-heartbeat, and every-tick event streams, followed by p95 echo -latency from a real `PtyBackend`, plus the event-to-next-frame p95 bound. The -draw-count and poll-bound assertions are deterministic; timings and PTY latency -are machine-specific evidence, not CI thresholds. +The attached TUI still polls its input and runtime queues every 16 ms so PTY output, snapshot results, tree watcher events, and log pages are noticed promptly. Polling is not a frame clock: `application::redraw::RedrawState` requests a draw only for a state-changing event, a terminal-size change, or a visible attention/search-caret phase change. The first frame is always drawn; an unchanged idle tick does no `Terminal::draw` call. Terminal polling reports output, title, resize, pane lifecycle, recovery, delayed synchronized-update, and settled-title activity so a redraw cannot depend only on keyboard input. The status list also schedules the exact Fresh→Warm (5 seconds) and Warm→Cool (`hot_window_secs`) boundaries for the active repository, so its fade remains correct without a 60 FPS frame clock. + +` r` remains an explicit full repaint: it clears ratatui's front buffer and marks the next loop dirty, covering terminal programs that left cells the diff renderer cannot know about. Input events also request a frame before their effects are observed, which keeps prefix/overlay/focus changes visible even when a PTY echo is delayed. Resize events and direct size observations share the same dirty path, so a missed crossterm resize notification cannot leave the new geometry unpainted. + +The release measurement is intentionally opt-in because it starts a real shell: `cargo test --release measure_dirty_redraw -- --ignored --nocapture`. It prints before/after draw counts and `ratatui::Terminal` CPU timings for idle, one-second-heartbeat, and every-tick event streams, followed by p95 echo latency from a real `PtyBackend`, plus the event-to-next-frame p95 bound. The draw-count and poll-bound assertions are deterministic; timings and PTY latency are machine-specific evidence, not CI thresholds. ## Notice Row -힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 -home-relative 표기), 현재 브랜치, upstream tracking 상태(`↑N ↓M`)를 노출한다. 브랜치/추적 정보는 -snapshot worker가 채워주고, detached HEAD/unborn branch처럼 값이 없으면 해당 칩만 생략한다. 마지막 -칩은 plugin이 보고한 pane recovery(state·deadline·attempt·detail)이며 대기 중인 것이 있을 때만 -나타난다 — [plugin-host.md](plugin-host.md)의 Recovery Surface 참고. - -**행에 안 들어가면 줄어드는 쪽은 두 이름이다**(`fit_names`). 경로와 브랜치는 `…`로 잘리고, 그 -뒤의 `↑N ↓M`과 recovery 칩은 제 폭을 지킨다 — 짧고, 이 행에서만 하는 말이기 때문이다. 브랜치는 -남은 자리의 **절반까지만** 가져가 긴 브랜치가 경로 자리를 통째로 먹지 않게 하고, 절반이 0이면 -아예 뺀다(`…` 하나는 브랜치 이름이 아니면서 칸은 차지한다). 절반이라는 몫은 web viewer의 footer와 -같다(`RepoShell.tsx`) — 같은 저장소가 두 화면에서 같게 읽혀야 한다. - -**알림(`App::notice`)이 올라오면 이 행을 덮는다.** 전용 행을 따로 만들지 않은 이유는 알림이 뜨고 -사라질 때마다 body가 한 행씩 줄었다 늘어나면서 **열려 있는 모든 PTY가 리사이즈**되기 때문이다. -이 행의 내용은 매 프레임 `App`에서 다시 계산되는 ambient 정보라 잠시 덮어도 잃는 것이 없다. -repo 다이얼로그가 열리면 우선순위가 뒤집힌다: 사용자가 편집 중인 입력 텍스트는 덮으면 안 되므로 -입력이 이 행을 차지하고, 알림과 Tab 후보는 그동안 hint 행으로 내려간다 -(`repo_dialog::repo_dialog_hint_line`). - -알림은 `Notice { kind: NoticeKind, text }` 타입이고, **만료는 메시지 문자열이 아니라 kind로 -판정한다**. 이전에는 `msg.starts_with("git error:")` 같은 접두사 매칭이라 (a) 사람이 읽는 문구에 -해제 로직이 묶여 있었고 (b) 매칭 arm이 없는 종류(`Terminal`/`Tree`/`Session`)는 repo를 바꾸기 -전까지 영영 사라지지 않았다. 해제 경로는 둘이다: - -- **같은 kind의 성공** — `App::clear_notice(kind)`. 각 서브시스템의 성공 경로에서 호출하며, 그 사이 - 도착한 다른 종류의 알림은 건드리지 않는다. -- **앱 레벨 키 입력** — `App::dismiss_notice_on_app_input()`. PTY로 그대로 포워딩되는 키는 - **제외**한다. 터미널 패널에서는 모든 키가 passthrough라 포함시키면 사용자가 타이핑을 재개하는 - 순간 알림이 사라져, 이 행이 막으려던 "보이지 않는 에러"로 되돌아간다. - -hint bar는 오버레이(repo 입력·prefix armed·swap target)가 열리면 그 내용으로 먼저 `return` -하므로, 알림이 거기 있던 시절에는 오버레이가 열린 동안 어떤 에러도 보이지 않았다. 알림을 별도 행으로 -분리하면서 이 경합 자체가 사라졌다. 지금 hint 행에 알림이 뜨는 경우는 repo 다이얼로그가 열려 -입력이 notice 행을 차지한 동안뿐이고, 그때도 알림은 legend보다 앞선 우선순위로 항상 보인다. +힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 home-relative 표기), 현재 브랜치, upstream tracking 상태(`↑N ↓M`)를 노출한다. 브랜치/추적 정보는 snapshot worker가 채워주고, detached HEAD/unborn branch처럼 값이 없으면 해당 칩만 생략한다. 마지막 칩은 plugin이 보고한 pane recovery(state·deadline·attempt·detail)이며 대기 중인 것이 있을 때만 나타난다 — [plugin-host.md](plugin-host.md)의 Recovery Surface 참고. + +**행에 안 들어가면 줄어드는 쪽은 두 이름이다**(`fit_names`). 경로와 브랜치는 `…`로 잘리고, 그 뒤의 `↑N ↓M`과 recovery 칩은 제 폭을 지킨다 — 짧고, 이 행에서만 하는 말이기 때문이다. 브랜치는 남은 자리의 **절반까지만** 가져가 긴 브랜치가 경로 자리를 통째로 먹지 않게 하고, 절반이 0이면 아예 뺀다(`…` 하나는 브랜치 이름이 아니면서 칸은 차지한다). 절반이라는 몫은 web viewer의 footer와 같다(`RepoShell.tsx`) — 같은 저장소가 두 화면에서 같게 읽혀야 한다. + +**알림(`App::notice`)이 올라오면 이 행을 덮는다.** 전용 행을 따로 만들지 않은 이유는 알림이 뜨고 사라질 때마다 body가 한 행씩 줄었다 늘어나면서 **열려 있는 모든 PTY가 리사이즈**되기 때문이다. 이 행의 내용은 매 프레임 `App`에서 다시 계산되는 ambient 정보라 잠시 덮어도 잃는 것이 없다. repo 다이얼로그가 열리면 우선순위가 뒤집힌다: 사용자가 편집 중인 입력 텍스트는 덮으면 안 되므로 입력이 이 행을 차지하고, 알림과 Tab 후보는 그동안 hint 행으로 내려간다 (`repo_dialog::repo_dialog_hint_line`). + +알림은 `Notice { kind: NoticeKind, text }` 타입이고, **만료는 메시지 문자열이 아니라 kind로 판정한다**. 이전에는 `msg.starts_with("git error:")` 같은 접두사 매칭이라 (a) 사람이 읽는 문구에 해제 로직이 묶여 있었고 (b) 매칭 arm이 없는 종류(`Terminal`/`Tree`/`Session`)는 repo를 바꾸기 전까지 영영 사라지지 않았다. 해제 경로는 둘이다: + +- **같은 kind의 성공** — `App::clear_notice(kind)`. 각 서브시스템의 성공 경로에서 호출하며, 그 사이 도착한 다른 종류의 알림은 건드리지 않는다. +- **앱 레벨 키 입력** — `App::dismiss_notice_on_app_input()`. PTY로 그대로 포워딩되는 키는 **제외**한다. 터미널 패널에서는 모든 키가 passthrough라 포함시키면 사용자가 타이핑을 재개하는 순간 알림이 사라져, 이 행이 막으려던 "보이지 않는 에러"로 되돌아간다. + +hint bar는 오버레이(repo 입력·prefix armed·swap target)가 열리면 그 내용으로 먼저 `return` 하므로, 알림이 거기 있던 시절에는 오버레이가 열린 동안 어떤 에러도 보이지 않았다. 알림을 별도 행으로 분리하면서 이 경합 자체가 사라졌다. 지금 hint 행에 알림이 뜨는 경우는 repo 다이얼로그가 열려 입력이 notice 행을 차지한 동안뿐이고, 그때도 알림은 legend보다 앞선 우선순위로 항상 보인다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/web.md b/docs/architecture/web.md index 1ba50a29..d18fc776 100644 --- a/docs/architecture/web.md +++ b/docs/architecture/web.md @@ -1,661 +1,152 @@ # Web Surface -브라우저 표면은 두 층으로 나뉜다. `src/web/common/`은 무엇을 서빙하는지 모르는 프리미티브 -(인증·HTTP 프레이밍·SSE·연결 회계)이고, `src/web/viewer/` + `viewer-ui/`가 실제 뷰어다. 뷰어는 -TUI와 **같은 데이터 계층을 읽어 DOM으로 렌더하는 두 번째 프론트엔드**로, `App`/`ui`/`input`을 전혀 -참조하지 않아 TUI 없이도(`nightcrow serve`) 동작하고 TUI와 별도 포트·쿠키·비밀번호를 쓴다. +브라우저 표면은 두 층으로 나뉜다. `src/web/common/`은 무엇을 서빙하는지 모르는 프리미티브 (인증·HTTP 프레이밍·SSE·연결 회계)이고, `src/web/viewer/` + `viewer-ui/`가 실제 뷰어다. 뷰어는 TUI와 **같은 데이터 계층을 읽어 DOM으로 렌더하는 두 번째 프론트엔드**로, `App`/`ui`/`input`을 전혀 참조하지 않아 TUI 없이도(`nightcrow serve`) 동작하고 TUI와 별도 포트·쿠키·비밀번호를 쓴다. ## 공용 웹 계층 (`src/web/common/`) git 데이터도 터미널도 전혀 모르는 계층이며, 웹 표면이 하나 더 생기더라도 공유는 정확히 여기까지다. -- **인증 (`common/auth.rs`)**: 비밀번호를 Argon2로 검증한다(code-server와 동일 방식). 평문 - `password`는 시작 시 메모리에서 해시하고, `hashed_password`(PHC)가 있으면 그쪽이 우선한다. 로그인은 - rate-limit(2/분 + 14/시간)되고 성공 시 httpOnly 세션 쿠키를 발급한다. **쿠키 이름은 서버가 - 정한다** — 같은 호스트의 다른 서버가 여기서 발급한 세션으로 인증되면 안 되므로 이름을 이 계층에 - 두지 않는다. 기본 바인딩은 loopback이며 **TLS는 없다** — 원격은 SSH 터널/리버스 프록시로 감싼다. - 서버 활성 시 비밀번호가 없으면 랜덤 생성해 config에 기록하고(주석 보존) 시작 시 1회 출력한다. -- **세션 영속 (`common/sessions.rs`)**: 세션 토큰은 `~/.nightcrow/sessions` 파일에 영속화되어 - 데몬 재시작 후에도 살아남는다. **수명은 스토어가 생성될 때 받는다** — 얼마나 오래 로그인을 - 유지할지는 데몬을 돌리는 사람의 판단이므로 상수가 아니라 `[web_viewer] session_ttl_hours`에서 - 온다. 쿠키 `Max-Age`도 같은 값에서 나오되 400일에서 잘린다(RFC 6265bis의 상한, Chrome 104+가 - 강제). `None`(= `session_ttl_hours = 0`)이면 토큰은 스스로 만료하지 않고 `never`로 기록된다 — - 숫자가 아니므로 이 형식을 모르는 빌드는 그 줄을 파싱 실패로 버린다. - **수명을 줄이면 이미 발급된 토큰에도 닿는다**: load가 각 만료를 `now + ttl`로 캡하되 낮추는 - 방향으로만 움직이고(`clamp`), 무언가 잘렸으면 **그 자리에서 파일에 쓴다**. 안 그러면 파일이 - 옛 만료를 계속 말하고, 수명보다 자주 재시작하는 세션은 매번 새 수명을 받아 조인 정책이 영영 - 적용되지 않는다. **만료 정리는 스케줄러 없이 쓰기에 얹는다**(`sweep`): 파일에 쓸 때마다 전체를 훑고, - 로드할 때 이미 지난 것을 버린다. `is_valid`의 지연 제거만으로는 **아무도 다시 묻지 않는 토큰**이 - 남는다 — 그 쿠키를 든 브라우저가 돌아오지 않으면 그 토큰은 영영 검사되지 않아, 데몬이 오래 살수록 - 파일이 로그인시킬 수 없는 세션을 세게 된다. 파일이 바뀌는 순간은 로그인·로그아웃·만료 토큰 제시 - 뿐이라 타이머를 둘 이유가 없다. **로그아웃은 서버측 취소** — - `revoke`가 메모리와 디스크 양쪽에서 토큰을 지우므로, 쿠키를 지우는 것만으로는 인증이 유지되지 - 않는다. 파일은 owner-only 권한(0o600)으로 생성되며(`platform::fs` seam), Windows에서는 - 대응 API가 없어 no-op이므로 운영자가 상태 디렉토리 위치로 통제한다. 파일이 손상되거나 읽을 수 - 없으면 빈 스토어로 시작한다 — 세션 파일 문제가 서버 시작을 막아서는 안 된다. -- **스트리밍 응답 (`common/sse.rs`)**: `http::response`는 항상 `Content-Length`와 `Connection: close`를 - 실으므로 소켓을 열어 둔 채 이벤트를 덧붙일 경로가 없다. `SseStream`은 자기 헤드를 직접 쓰고 그 - 시점부터 연결을 소유한다. 매 쓰기마다 flush하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다) 쓰기 - 실패를 그대로 전파한다 — 닫힌 탭은 다음 쓰기가 실패할 때만 알 수 있다. event 이름에 개행이 있으면 - 거부한다(SSE 필드 위조 가능). data는 개행마다 `data:` 라인으로 쪼개므로 별도 방어가 필요 없다. -- **연결 회계 (`common/conn.rs`)**: 연결마다 스레드가 하나씩 붙으므로 상한이 없으면 포트에 닿을 수 - 있는 누구나 프로세스를 고갈시킬 수 있다. 상한 초과분은 accept 루프에서 소켓을 닫는다(거기서 503을 - 쓰면 멈춘 클라이언트 하나가 뒤의 모든 연결을 막는다). 슬롯은 `ConnectionSlot`의 `Drop`으로 반납돼 - 장수하는 WS handler와 조기 에러 반환 양쪽에서 새지 않는다. +- **인증 (`common/auth.rs`)**: 비밀번호를 Argon2로 검증한다(code-server와 동일 방식). 평문 `password`는 시작 시 메모리에서 해시하고, `hashed_password`(PHC)가 있으면 그쪽이 우선한다. 로그인은 rate-limit(2/분 + 14/시간)되고 성공 시 httpOnly 세션 쿠키를 발급한다. **쿠키 이름은 서버가 정한다** — 같은 호스트의 다른 서버가 여기서 발급한 세션으로 인증되면 안 되므로 이름을 이 계층에 두지 않는다. 기본 바인딩은 loopback이며 **TLS는 없다** — 원격은 SSH 터널/리버스 프록시로 감싼다. 서버 활성 시 비밀번호가 없으면 랜덤 생성해 config에 기록하고(주석 보존) 시작 시 1회 출력한다. +- **세션 영속 (`common/sessions.rs`)**: 세션 토큰은 `~/.nightcrow/sessions` 파일에 영속화되어 데몬 재시작 후에도 살아남는다. **수명은 스토어가 생성될 때 받는다** — 얼마나 오래 로그인을 유지할지는 데몬을 돌리는 사람의 판단이므로 상수가 아니라 `[web_viewer] session_ttl_hours`에서 온다. 쿠키 `Max-Age`도 같은 값에서 나오되 400일에서 잘린다(RFC 6265bis의 상한, Chrome 104+가 강제). `None`(= `session_ttl_hours = 0`)이면 토큰은 스스로 만료하지 않고 `never`로 기록된다 — 숫자가 아니므로 이 형식을 모르는 빌드는 그 줄을 파싱 실패로 버린다. **수명을 줄이면 이미 발급된 토큰에도 닿는다**: load가 각 만료를 `now + ttl`로 캡하되 낮추는 방향으로만 움직이고(`clamp`), 무언가 잘렸으면 **그 자리에서 파일에 쓴다**. 안 그러면 파일이 옛 만료를 계속 말하고, 수명보다 자주 재시작하는 세션은 매번 새 수명을 받아 조인 정책이 영영 적용되지 않는다. **만료 정리는 스케줄러 없이 쓰기에 얹는다**(`sweep`): 파일에 쓸 때마다 전체를 훑고, 로드할 때 이미 지난 것을 버린다. `is_valid`의 지연 제거만으로는 **아무도 다시 묻지 않는 토큰**이 남는다 — 그 쿠키를 든 브라우저가 돌아오지 않으면 그 토큰은 영영 검사되지 않아, 데몬이 오래 살수록 파일이 로그인시킬 수 없는 세션을 세게 된다. 파일이 바뀌는 순간은 로그인·로그아웃·만료 토큰 제시 뿐이라 타이머를 둘 이유가 없다. **로그아웃은 서버측 취소** — `revoke`가 메모리와 디스크 양쪽에서 토큰을 지우므로, 쿠키를 지우는 것만으로는 인증이 유지되지 않는다. 파일은 owner-only 권한(0o600)으로 생성되며(`platform::fs` seam), Windows에서는 대응 API가 없어 no-op이므로 운영자가 상태 디렉토리 위치로 통제한다. 파일이 손상되거나 읽을 수 없으면 빈 스토어로 시작한다 — 세션 파일 문제가 서버 시작을 막아서는 안 된다. +- **스트리밍 응답 (`common/sse.rs`)**: `http::response`는 항상 `Content-Length`와 `Connection: close`를 실으므로 소켓을 열어 둔 채 이벤트를 덧붙일 경로가 없다. `SseStream`은 자기 헤드를 직접 쓰고 그 시점부터 연결을 소유한다. 매 쓰기마다 flush하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다) 쓰기 실패를 그대로 전파한다 — 닫힌 탭은 다음 쓰기가 실패할 때만 알 수 있다. event 이름에 개행이 있으면 거부한다(SSE 필드 위조 가능). data는 개행마다 `data:` 라인으로 쪼개므로 별도 방어가 필요 없다. +- **연결 회계 (`common/conn.rs`)**: 연결마다 스레드가 하나씩 붙으므로 상한이 없으면 포트에 닿을 수 있는 누구나 프로세스를 고갈시킬 수 있다. 상한 초과분은 accept 루프에서 소켓을 닫는다(거기서 503을 쓰면 멈춘 클라이언트 하나가 뒤의 모든 연결을 막는다). 슬롯은 `ConnectionSlot`의 `Drop`으로 반납돼 장수하는 WS handler와 조기 에러 반환 양쪽에서 새지 않는다. ## 서버 (`src/web/viewer/`) -**요청 처리 순서가 설계다**(`viewer/server/`): ① Host → ② Origin → ③ 정적 번들(인증 불필요) → -④ 인증 → ⑤ 저장소 조회 → ⑥ 경로 검증. - -- Host 검사가 Origin보다 앞이자 별개인 이유: `origin_allowed`는 Origin과 Host가 *일치한다*는 것만 - 증명하는데, DNS rebinding 공격자는 둘 다 통제하므로 그 조건을 자명하게 만족시킨다. loopback - 바인딩일 때 non-loopback Host를 거부해야 rebinding으로 얻는 same-origin 발판이 막힌다. -- 인증을 조회보다 **먼저** 하는 이유는, 그러지 않으면 미인증 클라이언트가 404와 401을 비교해 존재하는 - repo id를 열거할 수 있기 때문이다. 정적 번들이 인증 앞에 오는 이유는 그것이 로그인 폼을 그리는 - 주체이기 때문 — 게이팅하면 로그인할 방법 자체가 사라진다. -- **경로 검증은 헬퍼에서** 한다. 라우트마다 쓰면 빠뜨린다: 실제로 `/api/diff`가 - `../../etc/passwd`를 받아들였다. `load_file_diff`가 경로를 파일이 아니라 git pathspec으로 넘겨 - 검증기에 닿지 않았고 공격자의 경로를 그대로 되돌려줬다. **라우트가 "어떤 로더를 호출하느냐"에 따라 - 우연히 안전해서는 안 된다.** -- **`/api/preview`는 "API는 저장소에 *대해* 답하지, 저장소 파일*로* 답하지 않는다" 원칙의 유일한 - 예외다.** HTML 미리보기가 스크립트를 실행하려면 문서에 자기만의 CSP가 있어야 하는데(`srcdoc`은 - embedder 정책을 상속해 `script-src 'self'`가 인라인 스크립트를 막는다), 정책은 네트워크 응답만 - 실을 수 있다. 응답의 `sandbox allow-scripts`가 문서를 opaque origin으로 만들고(쿠키 없음, 요청은 - 전부 비인증에 `Origin: null`), `connect-src 'none'`이 나가는 채널을 전부 닫는다 — 무엇을 열고 - 무엇을 닫는지는 `server/preview.rs` 모듈 doc이 기준. 경로는 `/api/file`과 같은 로더·게이트를 - 지나고, iframe 쪽 `sandbox` 속성이 헤더와 교차 적용되는 이중 레이어다. - **게이트는 둘이고, 나뉘는 기준은 그 경로로 무엇을 하느냐다.** `with_repo_git_path`는 경로를 - **git에게 넘길 때** 쓰고 `validate_commit_path`로 검증한다 — 탈출·`.git`·NUL을 거부하는 순수 - 문자열 판정이며 **파일시스템을 보지 않는다**. `with_repo`는 이 프로세스가 **파일을 열 때** 쓰고 - `resolve_in_workdir`로 그 위에 **심링크 거부와 존재 확인**을 더한다. **탈출 방지는 전부 앞쪽에 - 있다** — 뒤쪽이 더하는 것은 열려는 파일을 지키는 것뿐이다. 그래서 앞쪽은 파일시스템이 이름을 - 어떻게 읽는지까지 알아야 한다. **요청한 이름과 열리는 파일이 다를 수 있다**: Windows는 컴포넌트 - 끝의 점·공백을 버리고(`.. `가 부모), NTFS는 `.git`에 `GIT~1`이라는 8.3 이름을 주며 `::$…`를 - 스트림 접미사로 읽고(`.git::$INDEX_ALLOCATION`이 `.git`), HFS+는 특정 zero-width 문자를 - 무시한다(`.git`가 `.git`). 규칙을 판정하기 전에 이 재작성들을 먼저 되돌린다 - (`effective_name`). git도 같은 것을 막지만 범위가 똑같지는 않다 — git은 콜론으로 나뉜 **모든** - 구간을 검사하고 여기서는 첫 구간만 본다. 뒤 구간은 앞 이름에 매달린 스트림이지 디렉터리가 - 아니라서(`x:.git`은 `x`의 스트림) 차이가 닿는 곳이 없다. git은 심링크를 대상 *이름*을 담은 - blob으로 다루므로(테스트로 고정: `path_gate.rs`) diff가 대상 파일 내용을 흘리지 않는다. - **반대로 틀리는 것도 같은 사고다** — 게이트를 강한 쪽으로 잘못 붙여, 워킹트리에 없다는 이유로 - **삭제된 파일의 diff가 400**이 되어 있었다. status 목록은 그 파일을 보여주는데 클릭하면 열리지 - 않았고, TUI는 게이트를 타지 않아 줄곧 정상이었다. 삭제는 탈출이 아니다. -- **diff 라우트의 `path`는 파일 하나를 가리킨다.** git pathspec은 디렉터리를 그 아래 전체의 prefix로 - 매칭하므로(`disable_pathspec_match`는 glob만 끈다) `path=src`가 `src` 아래 모든 변경을 `src`라는 - 이름표 하나로 답했다. 디렉터리인지 파일인지는 파일시스템을 봐야 알 수 있고 그것이 위에서 삭제된 - 파일을 막았던 바로 그 수단이므로, **수집 전에 거르지 않고 수집 후에 버린다** - (`collect_hunks`의 `only`). 디렉터리는 200 + 빈 diff — 변경이 없는 파일과 같은 답이다. 에러로 - 구분할 수 없다: 어느 쪽이든 남는 것은 빈 결과뿐이다. -- **저장소는 opaque id로만 지정**한다(`src/session/catalog/`). 클라이언트가 디렉토리를 이름 붙일 수 없으므로 - "어느 저장소인가"는 검증할 입력이 아니라 성공하거나 404가 되는 조회다. id는 프로세스 수명 동안 - 안정적이라 무관한 탭을 열고 닫아도 다른 id가 재배치되지 않는다. -- **카탈로그 경로는 경계에서 정규화**한다(`Catalog::normalized`). `set_paths`·`add_path`·`remove_path`· - `reorder`가 모두 `resolve_repo_path`의 단일 철자를 사용한다. served 집합·`hidden`·`order`가 문자열로 - 동일성을 판단하므로, 중첩 디렉터리·심링크·끝 슬래시로 같은 worktree를 다시 열어도 탭이 중복되지 않는다. - 기존 저장 상태의 옛 철자는 다음 쓰기에서 정규화되며, 일시적으로 active/pane preference가 기본값으로 - 돌아올 수는 있어도 영구 마이그레이션 코드를 두지는 않는다. -- **저장소별 런타임**(`src/session/runtime/`): `SnapshotChannel`은 단일 consumer `mpsc`라 자기 것을 띄운다. - 스냅샷을 wire 페이로드로 한 번만 줄여 팬아웃한다. **팬아웃은 conflate**된다 — 느린 구독자는 최신 - 상태를 받지 밀린 과거를 재생하지 않는다(슬롯 1개 + 1-depth 병합 wakeup). 소켓 I/O 중 락을 잡지 - 않는다. 페이로드가 직전과 동일하면 발행하지 않는다: producer는 변화가 아니라 타이머로 tick하므로, - 그러지 않으면 유휴 저장소가 매초 스트리밍하며 seq를 태워 "뭔가 바뀌었나"의 지표로 쓸 수 없게 된다. -- **터미널**(`src/session/terminal/`)은 **세션의 터미널이고 attach한 TUI가 보는 것과 같은 pane**이다 - ([session.md](session.md#세션-공유-데몬--클라이언트) 참고). raw PTY 바이트를 그대로 보낸다 — - **화면은 서버가 그리지 않는다**(xterm.js가 이미 에뮬레이터다). 허브가 스트림을 파싱하는 것은 딱 - 한 가지, 다른 방법으로는 알 수 없는 **pane의 모드**를 위해서다. 4바이트 LE pane id를 앞에 붙인 - **바이너리 프레임** — PTY 읽기는 멀티바이트 시퀀스를 일상적으로 쪼개므로 JSON으로 조기 디코딩하면 - 브라우저가 재조립하기 전에 깨진다. **출력은 conflate하지 않고 큐잉**한다: 최신 status는 완결된 - 그림이지만 터미널 바이트는 하나만 빠져도 스트림이 깨지므로, 큐를 넘긴 클라이언트는 조용히 버리지 - 않고 끊는다. **끊는다는 것은 소켓까지다**(`Client::cut_off`) — broadcast 목록에서 빼는 것만으로는 - 절반이다. 연결 스레드는 `ws.read()`에 들어가 있어 큐가 비었다는 것을 알 방법이 없고, 그러면 그 - 페이지는 **연결된 채 멈춘 화면**을 들고 있게 된다. `onclose`에 달린 재접속이 발화하지 않으므로 - 스스로 복구하지도 못한다. 그래서 hub가 소켓 핸들을 하나 더 들고 `shutdown`한다 — 데몬이 attach한 - 클라이언트에 이미 쓰는 방법과 같다(`daemon::clients`). 데몬 브리지에는 핸들을 주지 않는데 - (`None`), 그쪽 워커는 hub 세션을 폴링해 논블로킹으로 넘기므로 여기서 밀릴 수 없고 백프레셔는 그 - 바깥 계층이 처리하기 때문이다. -- **멈춘 것과 떠난 것을 구분한다**(`stalled_not_gone`). 양방향에 타임아웃이 걸려 있다 — 읽기는 - 10 ms 폴이라 한 스레드가 두 방향을 다 돌보고, 쓰기는 15초라 안 읽는 클라이언트가 스레드를 영영 - 물고 있지 못한다. 둘 다 macOS에서 `WouldBlock`, Linux에서 `TimedOut`으로 온다. 쓰기 쪽이 이것을 - 끊김으로 읽는 동안 **15초 동안 못 읽은 페이지는 소켓이 닫히고 모든 pane을 replay로 다시 세웠다** - — 폰이 잠들거나 터널이 재협상하면 그렇게 된다. tungstenite도 같은 자리에 선을 긋는다: `Io` 에러는 - "WouldBlock을 빼면" 치명적이고, 못 나간 프레임은 자기 write buffer에 남겨 다음 `write`/`flush`가 - 마저 보낸다. 그래서 이쪽은 기다리고, 기다리는 동안 **hub에서 프레임을 더 꺼내지 않는다** — 밀리는 - 것이 상한이 있는 곳에 쌓이게 두는 것이다. **정말 못 따라오는 클라이언트를 끊는 일은 위의 큐 상한 - 하나가 맡는다**. 상한이 두 곳에 있으면 느린 클라이언트는 둘 중 아무 쪽에나 걸린다. -- **연결이 끝나면 이유가 남는다.** 정상 종료·에러 종료는 INFO(`viewer: terminal socket ended`, - 어느 동작 중이었는지를 `during`으로), 큐 초과로 **강제로 끊는 것은 WARN**이다. 페이지는 끊긴 - 소켓에 replay로 답하므로 사람이 그것을 보는데, 전에는 DEBUG뿐이라 기본 레벨에 아무 흔적이 - 없었다 — "가끔 뷰어가 튕긴다"를 코드에서 역산해야 했던 이유다. +**요청 처리 순서가 설계다**(`viewer/server/`): ① Host → ② Origin → ③ 정적 번들(인증 불필요) → ④ 인증 → ⑤ 저장소 조회 → ⑥ 경로 검증. + +- Host 검사가 Origin보다 앞이자 별개인 이유: `origin_allowed`는 Origin과 Host가 *일치한다*는 것만 증명하는데, DNS rebinding 공격자는 둘 다 통제하므로 그 조건을 자명하게 만족시킨다. loopback 바인딩일 때 non-loopback Host를 거부해야 rebinding으로 얻는 same-origin 발판이 막힌다. +- 인증을 조회보다 **먼저** 하는 이유는, 그러지 않으면 미인증 클라이언트가 404와 401을 비교해 존재하는 repo id를 열거할 수 있기 때문이다. 정적 번들이 인증 앞에 오는 이유는 그것이 로그인 폼을 그리는 주체이기 때문 — 게이팅하면 로그인할 방법 자체가 사라진다. +- **경로 검증은 헬퍼에서** 한다. 라우트마다 쓰면 빠뜨린다: 실제로 `/api/diff`가 `../../etc/passwd`를 받아들였다. `load_file_diff`가 경로를 파일이 아니라 git pathspec으로 넘겨 검증기에 닿지 않았고 공격자의 경로를 그대로 되돌려줬다. **라우트가 "어떤 로더를 호출하느냐"에 따라 우연히 안전해서는 안 된다.** +- **`/api/preview`는 "API는 저장소에 *대해* 답하지, 저장소 파일*로* 답하지 않는다" 원칙의 유일한 예외다.** HTML 미리보기가 스크립트를 실행하려면 문서에 자기만의 CSP가 있어야 하는데(`srcdoc`은 embedder 정책을 상속해 `script-src 'self'`가 인라인 스크립트를 막는다), 정책은 네트워크 응답만 실을 수 있다. 응답의 `sandbox allow-scripts`가 문서를 opaque origin으로 만들고(쿠키 없음, 요청은 전부 비인증에 `Origin: null`), `connect-src 'none'`이 나가는 채널을 전부 닫는다 — 무엇을 열고 무엇을 닫는지는 `server/preview.rs` 모듈 doc이 기준. 경로는 `/api/file`과 같은 로더·게이트를 지나고, iframe 쪽 `sandbox` 속성이 헤더와 교차 적용되는 이중 레이어다. **게이트는 둘이고, 나뉘는 기준은 그 경로로 무엇을 하느냐다.** `with_repo_git_path`는 경로를 **git에게 넘길 때** 쓰고 `validate_commit_path`로 검증한다 — 탈출·`.git`·NUL을 거부하는 순수 문자열 판정이며 **파일시스템을 보지 않는다**. `with_repo`는 이 프로세스가 **파일을 열 때** 쓰고 `resolve_in_workdir`로 그 위에 **심링크 거부와 존재 확인**을 더한다. **탈출 방지는 전부 앞쪽에 있다** — 뒤쪽이 더하는 것은 열려는 파일을 지키는 것뿐이다. 그래서 앞쪽은 파일시스템이 이름을 어떻게 읽는지까지 알아야 한다. **요청한 이름과 열리는 파일이 다를 수 있다**: Windows는 컴포넌트 끝의 점·공백을 버리고(`.. `가 부모), NTFS는 `.git`에 `GIT~1`이라는 8.3 이름을 주며 `::$…`를 스트림 접미사로 읽고(`.git::$INDEX_ALLOCATION`이 `.git`), HFS+는 특정 zero-width 문자를 무시한다(`.git`가 `.git`). 규칙을 판정하기 전에 이 재작성들을 먼저 되돌린다 (`effective_name`). git도 같은 것을 막지만 범위가 똑같지는 않다 — git은 콜론으로 나뉜 **모든** 구간을 검사하고 여기서는 첫 구간만 본다. 뒤 구간은 앞 이름에 매달린 스트림이지 디렉터리가 아니라서(`x:.git`은 `x`의 스트림) 차이가 닿는 곳이 없다. git은 심링크를 대상 *이름*을 담은 blob으로 다루므로(테스트로 고정: `path_gate.rs`) diff가 대상 파일 내용을 흘리지 않는다. **반대로 틀리는 것도 같은 사고다** — 게이트를 강한 쪽으로 잘못 붙여, 워킹트리에 없다는 이유로 **삭제된 파일의 diff가 400**이 되어 있었다. status 목록은 그 파일을 보여주는데 클릭하면 열리지 않았고, TUI는 게이트를 타지 않아 줄곧 정상이었다. 삭제는 탈출이 아니다. +- **diff 라우트의 `path`는 파일 하나를 가리킨다.** git pathspec은 디렉터리를 그 아래 전체의 prefix로 매칭하므로(`disable_pathspec_match`는 glob만 끈다) `path=src`가 `src` 아래 모든 변경을 `src`라는 이름표 하나로 답했다. 디렉터리인지 파일인지는 파일시스템을 봐야 알 수 있고 그것이 위에서 삭제된 파일을 막았던 바로 그 수단이므로, **수집 전에 거르지 않고 수집 후에 버린다** (`collect_hunks`의 `only`). 디렉터리는 200 + 빈 diff — 변경이 없는 파일과 같은 답이다. 에러로 구분할 수 없다: 어느 쪽이든 남는 것은 빈 결과뿐이다. +- **저장소는 opaque id로만 지정**한다(`src/session/catalog/`). 클라이언트가 디렉토리를 이름 붙일 수 없으므로 "어느 저장소인가"는 검증할 입력이 아니라 성공하거나 404가 되는 조회다. id는 프로세스 수명 동안 안정적이라 무관한 탭을 열고 닫아도 다른 id가 재배치되지 않는다. +- **카탈로그 경로는 경계에서 정규화**한다(`Catalog::normalized`). `set_paths`·`add_path`·`remove_path`· `reorder`가 모두 `resolve_repo_path`의 단일 철자를 사용한다. served 집합·`hidden`·`order`가 문자열로 동일성을 판단하므로, 중첩 디렉터리·심링크·끝 슬래시로 같은 worktree를 다시 열어도 탭이 중복되지 않는다. 기존 저장 상태의 옛 철자는 다음 쓰기에서 정규화되며, 일시적으로 active/pane preference가 기본값으로 돌아올 수는 있어도 영구 마이그레이션 코드를 두지는 않는다. +- **저장소별 런타임**(`src/session/runtime/`): `SnapshotChannel`은 단일 consumer `mpsc`라 자기 것을 띄운다. 스냅샷을 wire 페이로드로 한 번만 줄여 팬아웃한다. **팬아웃은 conflate**된다 — 느린 구독자는 최신 상태를 받지 밀린 과거를 재생하지 않는다(슬롯 1개 + 1-depth 병합 wakeup). 소켓 I/O 중 락을 잡지 않는다. 페이로드가 직전과 동일하면 발행하지 않는다: producer는 변화가 아니라 타이머로 tick하므로, 그러지 않으면 유휴 저장소가 매초 스트리밍하며 seq를 태워 "뭔가 바뀌었나"의 지표로 쓸 수 없게 된다. +- **터미널**(`src/session/terminal/`)은 **세션의 터미널이고 attach한 TUI가 보는 것과 같은 pane**이다 ([session.md](session.md#세션-공유-데몬--클라이언트) 참고). raw PTY 바이트를 그대로 보낸다 — **화면은 서버가 그리지 않는다**(xterm.js가 이미 에뮬레이터다). 허브가 스트림을 파싱하는 것은 딱 한 가지, 다른 방법으로는 알 수 없는 **pane의 모드**를 위해서다. 4바이트 LE pane id를 앞에 붙인 **바이너리 프레임** — PTY 읽기는 멀티바이트 시퀀스를 일상적으로 쪼개므로 JSON으로 조기 디코딩하면 브라우저가 재조립하기 전에 깨진다. **출력은 conflate하지 않고 큐잉**한다: 최신 status는 완결된 그림이지만 터미널 바이트는 하나만 빠져도 스트림이 깨지므로, 큐를 넘긴 클라이언트는 조용히 버리지 않고 끊는다. **끊는다는 것은 소켓까지다**(`Client::cut_off`) — broadcast 목록에서 빼는 것만으로는 절반이다. 연결 스레드는 `ws.read()`에 들어가 있어 큐가 비었다는 것을 알 방법이 없고, 그러면 그 페이지는 **연결된 채 멈춘 화면**을 들고 있게 된다. `onclose`에 달린 재접속이 발화하지 않으므로 스스로 복구하지도 못한다. 그래서 hub가 소켓 핸들을 하나 더 들고 `shutdown`한다 — 데몬이 attach한 클라이언트에 이미 쓰는 방법과 같다(`daemon::clients`). 데몬 브리지에는 핸들을 주지 않는데 (`None`), 그쪽 워커는 hub 세션을 폴링해 논블로킹으로 넘기므로 여기서 밀릴 수 없고 백프레셔는 그 바깥 계층이 처리하기 때문이다. +- **멈춘 것과 떠난 것을 구분한다**(`stalled_not_gone`). 양방향에 타임아웃이 걸려 있다 — 읽기는 10 ms 폴이라 한 스레드가 두 방향을 다 돌보고, 쓰기는 15초라 안 읽는 클라이언트가 스레드를 영영 물고 있지 못한다. 둘 다 macOS에서 `WouldBlock`, Linux에서 `TimedOut`으로 온다. 쓰기 쪽이 이것을 끊김으로 읽는 동안 **15초 동안 못 읽은 페이지는 소켓이 닫히고 모든 pane을 replay로 다시 세웠다** — 폰이 잠들거나 터널이 재협상하면 그렇게 된다. tungstenite도 같은 자리에 선을 긋는다: `Io` 에러는 "WouldBlock을 빼면" 치명적이고, 못 나간 프레임은 자기 write buffer에 남겨 다음 `write`/`flush`가 마저 보낸다. 그래서 이쪽은 기다리고, 기다리는 동안 **hub에서 프레임을 더 꺼내지 않는다** — 밀리는 것이 상한이 있는 곳에 쌓이게 두는 것이다. **정말 못 따라오는 클라이언트를 끊는 일은 위의 큐 상한 하나가 맡는다**. 상한이 두 곳에 있으면 느린 클라이언트는 둘 중 아무 쪽에나 걸린다. +- **연결이 끝나면 이유가 남는다.** 정상 종료·에러 종료는 INFO(`viewer: terminal socket ended`, 어느 동작 중이었는지를 `during`으로), 큐 초과로 **강제로 끊는 것은 WARN**이다. 페이지는 끊긴 소켓에 replay로 답하므로 사람이 그것을 보는데, 전에는 DEBUG뿐이라 기본 레벨에 아무 흔적이 없었다 — "가끔 뷰어가 튕긴다"를 코드에서 역산해야 했던 이유다. - **자원 상한**(`limits.rs`)은 전부 `truncated`로 보고된다. 잘린 목록이 전체인 척하지 않는다. ### PTY 크기는 확정된 값만 전달한다 (`usePaneSizes.ts`, `ServerMessage::Created`) -리사이즈는 싼 메시지가 아니다 — 자식은 SIGWINCH를 받고 풀스크린 프로그램은 화면을 통째로 다시 -그린다. 그래서 네 가지를 막는다. - -1. **중간값을 보내지 않는다**: 브라우저는 최종 기하에 도달하기까지 여러 중간 상태를 지난다(두 번째 - pane이 생기며 그리드가 쪼개짐, 웹폰트 로딩, 브레이크포인트 전환). `fit()`은 즉시 돌리되 — xterm - 자기 버퍼만 reflow하고 선을 타지 않으므로 드래그가 매끄럽다 — 서버로 보내는 것만 레이아웃이 멈춘 - 뒤로 미룬다. -2. **`created`가 pane의 현재 크기를 싣는다**: pane의 크기를 아는 것은 그것을 정한 페이지뿐이라, - 재접속한 클라이언트는 자기 크기를 보내야 했고 값이 같아도 자식은 한 번 다시 그렸다. 이제 - 클라이언트가 그 크기를 채택하므로 같은 레이아웃으로 리로드하면 리사이즈가 0번이다. **그 0번이 - 화면 복원을 대신 하고 있었다** — 이 전제를 잃어 실제 사고가 났고(자세한 것은 - [session.md](session.md#스크롤백과-재접속)), 지금은 허브가 화면 자체를 들고 있다가 replay하므로 - 이 최적화는 그대로 유지된다 — 화면 복원이 resize에 얹혀 있지 않다. -3. **크기를 모르는 PTY는 만들지 않는다**: 접속하면 서버가 `pending`으로 "사이즈 대기 중인 startup - 터미널 N개"를 알리고, 클라이언트가 그 pane들이 차지할 셀을 placeholder로 렌더해 **실제 DOM을 - 재서** `start`로 답한 뒤에야 PTY가 생긴다(`useStartupSizes`). 그리드 산술이 아니라 버려지는 xterm - 하나를 그 셀에 열어 `proposeDimensions()`로 재는데, gap과 셀 헤더를 다시 유도하다 어긋나면 그 - 오차가 곧 이 핸드셰이크가 없애려던 "잘못된 크기로 태어남"이기 때문이다. **타임아웃은 두지 - 않는다** — 임의의 시간 상수는 기기마다 다른 브라우저 레이아웃 타이밍을 하나로 못 박는다. 측정 - 실패의 fallback은 **클라이언트**에 두고(실패했음을 아는 쪽이 거기다), `started` 플래그를 접속이 - 아니라 **`start` 도착 시점에 소비**한다 — 핸드셰이크 도중 끊긴 페이지가 터미널을 데려가지 못한다. - 둘이 동시에 답하면 CAS로 첫 번째만 이겨 pane은 정확히 한 번 생긴다. -4. **replay가 몇 개를 줄지 미리 알린다**(`ServerMessage::Hello`의 `panes`): 2번이 약속하는 "리로드 - 리사이즈 0번"은 pane이 여러 개면 성립하지 않았다. replay는 pane을 하나씩(각각 뒤에 자기 스크롤백) - 보내므로 클라이언트가 **가진 것만으로 그리드를 짰고**, 첫 pane이 패널 전체를 차지했다가 다음 - pane이 오면 줄었다. 스크롤백이 커서 그 간격이 settle(60ms)을 넘으면 그 잘못된 그리드 크기가 실제 - PTY로 나갔다가 되돌아왔다. 이제 `hello`가 올 pane 수를 싣고 클라이언트가 **최종 그리드를 처음부터 - 그려서**, 각 pane이 자기가 계속 쓸 셀로 도착한다. 개수는 정확하다 — `connect`가 락을 쥔 채 replay - 전체를 큐잉하고 클라이언트 등록은 그 뒤라, 그 프레임들 사이에 broadcast가 끼어들 수 없다. - 클라이언트는 pane 목록과 비교하지 않고 **하나씩 카운트를 깎는다**: replay 도중 pane이 죽으면 - 목표에 영영 도달하지 못해 빈 셀이 영구히 남는다. **zoom만은 개수로 예측할 수 없어**(최종 레이아웃이 - pane 하나짜리다) 그 pane이 도착할 때까지 fit 자체를 보류한다 — 그 사이 그리드 셀에 맞춰진 pane은 - zoom이 걸리는 순간 숨겨지면서 잘못된 크기를 그대로 안고 남는다. +리사이즈는 싼 메시지가 아니다 — 자식은 SIGWINCH를 받고 풀스크린 프로그램은 화면을 통째로 다시 그린다. 그래서 네 가지를 막는다. + +1. **중간값을 보내지 않는다**: 브라우저는 최종 기하에 도달하기까지 여러 중간 상태를 지난다(두 번째 pane이 생기며 그리드가 쪼개짐, 웹폰트 로딩, 브레이크포인트 전환). `fit()`은 즉시 돌리되 — xterm 자기 버퍼만 reflow하고 선을 타지 않으므로 드래그가 매끄럽다 — 서버로 보내는 것만 레이아웃이 멈춘 뒤로 미룬다. +2. **`created`가 pane의 현재 크기를 싣는다**: pane의 크기를 아는 것은 그것을 정한 페이지뿐이라, 재접속한 클라이언트는 자기 크기를 보내야 했고 값이 같아도 자식은 한 번 다시 그렸다. 이제 클라이언트가 그 크기를 채택하므로 같은 레이아웃으로 리로드하면 리사이즈가 0번이다. **그 0번이 화면 복원을 대신 하고 있었다** — 이 전제를 잃어 실제 사고가 났고(자세한 것은 [session.md](session.md#스크롤백과-재접속)), 지금은 허브가 화면 자체를 들고 있다가 replay하므로 이 최적화는 그대로 유지된다 — 화면 복원이 resize에 얹혀 있지 않다. +3. **크기를 모르는 PTY는 만들지 않는다**: 접속하면 서버가 `pending`으로 "사이즈 대기 중인 startup 터미널 N개"를 알리고, 클라이언트가 그 pane들이 차지할 셀을 placeholder로 렌더해 **실제 DOM을 재서** `start`로 답한 뒤에야 PTY가 생긴다(`useStartupSizes`). 그리드 산술이 아니라 버려지는 xterm 하나를 그 셀에 열어 `proposeDimensions()`로 재는데, gap과 셀 헤더를 다시 유도하다 어긋나면 그 오차가 곧 이 핸드셰이크가 없애려던 "잘못된 크기로 태어남"이기 때문이다. **타임아웃은 두지 않는다** — 임의의 시간 상수는 기기마다 다른 브라우저 레이아웃 타이밍을 하나로 못 박는다. 측정 실패의 fallback은 **클라이언트**에 두고(실패했음을 아는 쪽이 거기다), `started` 플래그를 접속이 아니라 **`start` 도착 시점에 소비**한다 — 핸드셰이크 도중 끊긴 페이지가 터미널을 데려가지 못한다. 둘이 동시에 답하면 CAS로 첫 번째만 이겨 pane은 정확히 한 번 생긴다. +4. **replay가 몇 개를 줄지 미리 알린다**(`ServerMessage::Hello`의 `panes`): 2번이 약속하는 "리로드 리사이즈 0번"은 pane이 여러 개면 성립하지 않았다. replay는 pane을 하나씩(각각 뒤에 자기 스크롤백) 보내므로 클라이언트가 **가진 것만으로 그리드를 짰고**, 첫 pane이 패널 전체를 차지했다가 다음 pane이 오면 줄었다. 스크롤백이 커서 그 간격이 settle(60ms)을 넘으면 그 잘못된 그리드 크기가 실제 PTY로 나갔다가 되돌아왔다. 이제 `hello`가 올 pane 수를 싣고 클라이언트가 **최종 그리드를 처음부터 그려서**, 각 pane이 자기가 계속 쓸 셀로 도착한다. 개수는 정확하다 — `connect`가 락을 쥔 채 replay 전체를 큐잉하고 클라이언트 등록은 그 뒤라, 그 프레임들 사이에 broadcast가 끼어들 수 없다. 클라이언트는 pane 목록과 비교하지 않고 **하나씩 카운트를 깎는다**: replay 도중 pane이 죽으면 목표에 영영 도달하지 못해 빈 셀이 영구히 남는다. **zoom만은 개수로 예측할 수 없어**(최종 레이아웃이 pane 하나짜리다) 그 pane이 도착할 때까지 fit 자체를 보류한다 — 그 사이 그리드 셀에 맞춰진 pane은 zoom이 걸리는 순간 숨겨지면서 잘못된 크기를 그대로 안고 남는다. ### 순서는 서버가 authoritative하다 -- **터미널 pane 순서**(`src/session/terminal/hub_layout.rs::reorder_panes`, `lib/paneOrder.ts`): 클라이언트가 pane 헤더를 - 드래그하면 원하는 전체 순서를 `reorder`로 보내고, hub가 살아있는 pane에 맞춰 재조정한 뒤 - (`canonical_order`: 요청 순서 중 실재하는 id 먼저, 요청이 빠뜨린 live pane은 현재 순서로 뒤에, - 모르는 id·중복은 버림) canonical 순서를 `reordered`로 **전 클라이언트에 broadcast**한다. - 클라이언트는 낙관적으로 미리 바꾸지 않고 이 echo를 받아 반영해(`reconcileOrder`) 여러 기기가 한 - 순서로 수렴한다. 순서는 hub의 pane Vec에 살아 재접속 replay와 다른 기기가 자동으로 따라오고 - 디스크에는 쓰지 않는다. DnD는 HTML5 drag가 아니라 pointer 이벤트라(sidebar divider와 같은 선택) - 폰 터치도 마우스와 동일하다. -- **어느 pane이 패널을 채우는지(zoom)**(`src/session/terminal/hub_zoom.rs`, `lib/zoom.ts`): 순서와 같은 자리·같은 - 이유다. 클라이언트는 `zoom`을 보낸 뒤 `zoomed` echo로만 반영하고, `connect`가 현재 zoom을 재생해 - **새로고침한 페이지가 zoom한 채로 돌아온다** — 전에는 한 페이지의 `useState`에 살아 리로드마다 - 사라졌다. **프레임 순서가 계약이다**: `Created`보다 zoom 해제가 먼저, replay에서는 pane보다 zoom이 - 먼저 간다(각각 "새 pane이 zoom 뒤에 숨는 렌더"와 "grid로 정착했다가 전 PTY를 다시 리사이즈"를 - 막는다). 클라이언트는 무엇을 그릴지를 raw 값이 아니라 살아있는 pane 목록에서 파생시켜 - (`renderedZoom`) 두 프레임 사이의 렌더에서 빈 패널이 나오지 않게 한다. **디스크에는 쓰지 않으며 쓸 - 수도 없다** — zoom은 pane을 가리키고 pane은 데몬의 자식이라 재시작하면 가리킬 대상이 없다. 패널 - 단위 maximize(`src/session/prefs/maximized.rs`)가 파일에 남는 것과의 차이가 이것이다. **attach한 TUI는 - 통보받고 무시한다**(`backend/hub.rs`): TUI의 zoom은 자기 활성 pane을 따르고 diff 뷰어까지 덮는 다른 - 질문이다. -- **프로젝트 탭 순서**(`src/session/catalog/`, `POST /api/repos/order`): 같은 모양이되 **전송 채널이 다르다** — - repo 목록에는 전용 WebSocket이 없고 `/api/repos` 폴링뿐이라 broadcast 대신 REST로 갱신하고 다음 - 폴링이 그것을 받는다. **순서가 `rebuild`를 견디게** `Catalog`에 명시적 `order` overlay를 두어 - `union_paths`가 base+added 자연 순서를 그 위에 정렬한다(순서에 없는 새 repo는 끝에). **닫으면 자리도 - 잊는다** — `remove_path`가 `hidden`에 넣을 때 `added`뿐 아니라 `base`·`order`에서도 지운다. 남겨두면 - `add_path`가 이미 union에 있는 경로로 보고 append하지 않아 다시 연 탭이 방금 연 자리(끝)가 아니라 - 예전 자리로 되돌아가는데, `base`는 데몬 수명 동안 시작 시 한 번만 쓰이므로 그 기억은 스스로 - 사라지지 않는다. 폴링 스냅백은 세 겹으로 막는다: write-generation 가드(`repoOrderWrites`), - 드래그 중 차단(`repoDraggingRef`), - 그리고 **reorder POST가 in-flight/큐에 있는 동안 폴링이 순서를 채택하지 않는** pending 가드. 가드가 - 걸린 폴링은 서버 순서를 버리되 membership은 `reconcileOrder`로 받아들인다. **reorder POST는 - 클라이언트에서 직렬화**한다(한 번에 하나, 큐에는 최신 순서만) — 두 POST가 별도 커넥션이라 서버가 - 옛 요청을 나중에 커밋해 잘못된 순서로 영속할 수 있다. **남는 transient 하나**: 커밋 전 서버를 - 읽었지만 POST가 정착한 뒤 도착하는 폴링은 한 번 스냅백할 수 있다 — 자기교정되는 클래스라 서버 - revision을 도입하지 않는다. -- **영속은 open/close와 같은 경계**를 따른다: headless `serve`(`persist=true`)면 `catalog.paths()`가 - `workspace.json`의 탭 순서로 저장되고, TUI 동반 실행에서는 세션 한정이다(그 파일의 주인이 TUI다). - 저장 시 `persist_workspace`는 `ws.active`를 인덱스가 아니라 **이전 활성 path 기준으로 - 재매핑**한다. **한계**: `serve`에 `--repo`를 명시하면 그 인자가 시작 순서를 지배해 저장된 재정렬이 - 재시작 때 덮인다. 또 `catalog.reorder`와 이어지는 `persist_workspace`(파일 IO)는 한 트랜잭션이 - 아니라 두 기기가 밀리초 안에 동시에 재정렬하면 파일이 한 박자 뒤처질 수 있다(라이브 catalog는 - 항상 정확). +- **터미널 pane 순서**(`src/session/terminal/hub_layout.rs::reorder_panes`, `lib/paneOrder.ts`): 클라이언트가 pane 헤더를 드래그하면 원하는 전체 순서를 `reorder`로 보내고, hub가 살아있는 pane에 맞춰 재조정한 뒤 (`canonical_order`: 요청 순서 중 실재하는 id 먼저, 요청이 빠뜨린 live pane은 현재 순서로 뒤에, 모르는 id·중복은 버림) canonical 순서를 `reordered`로 **전 클라이언트에 broadcast**한다. 클라이언트는 낙관적으로 미리 바꾸지 않고 이 echo를 받아 반영해(`reconcileOrder`) 여러 기기가 한 순서로 수렴한다. 순서는 hub의 pane Vec에 살아 재접속 replay와 다른 기기가 자동으로 따라오고 디스크에는 쓰지 않는다. DnD는 HTML5 drag가 아니라 pointer 이벤트라(sidebar divider와 같은 선택) 폰 터치도 마우스와 동일하다. +- **어느 pane이 패널을 채우는지(zoom)**(`src/session/terminal/hub_zoom.rs`, `lib/zoom.ts`): 순서와 같은 자리·같은 이유다. 클라이언트는 `zoom`을 보낸 뒤 `zoomed` echo로만 반영하고, `connect`가 현재 zoom을 재생해 **새로고침한 페이지가 zoom한 채로 돌아온다** — 전에는 한 페이지의 `useState`에 살아 리로드마다 사라졌다. **프레임 순서가 계약이다**: `Created`보다 zoom 해제가 먼저, replay에서는 pane보다 zoom이 먼저 간다(각각 "새 pane이 zoom 뒤에 숨는 렌더"와 "grid로 정착했다가 전 PTY를 다시 리사이즈"를 막는다). 클라이언트는 무엇을 그릴지를 raw 값이 아니라 살아있는 pane 목록에서 파생시켜 (`renderedZoom`) 두 프레임 사이의 렌더에서 빈 패널이 나오지 않게 한다. **디스크에는 쓰지 않으며 쓸 수도 없다** — zoom은 pane을 가리키고 pane은 데몬의 자식이라 재시작하면 가리킬 대상이 없다. 패널 단위 maximize(`src/session/prefs/maximized.rs`)가 파일에 남는 것과의 차이가 이것이다. **attach한 TUI는 통보받고 무시한다**(`backend/hub.rs`): TUI의 zoom은 자기 활성 pane을 따르고 diff 뷰어까지 덮는 다른 질문이다. +- **프로젝트 탭 순서**(`src/session/catalog/`, `POST /api/repos/order`): 같은 모양이되 **전송 채널이 다르다** — repo 목록에는 전용 WebSocket이 없고 `/api/repos` 폴링뿐이라 broadcast 대신 REST로 갱신하고 다음 폴링이 그것을 받는다. **순서가 `rebuild`를 견디게** `Catalog`에 명시적 `order` overlay를 두어 `union_paths`가 base+added 자연 순서를 그 위에 정렬한다(순서에 없는 새 repo는 끝에). **닫으면 자리도 잊는다** — `remove_path`가 `hidden`에 넣을 때 `added`뿐 아니라 `base`·`order`에서도 지운다. 남겨두면 `add_path`가 이미 union에 있는 경로로 보고 append하지 않아 다시 연 탭이 방금 연 자리(끝)가 아니라 예전 자리로 되돌아가는데, `base`는 데몬 수명 동안 시작 시 한 번만 쓰이므로 그 기억은 스스로 사라지지 않는다. 폴링 스냅백은 세 겹으로 막는다: write-generation 가드(`repoOrderWrites`), 드래그 중 차단(`repoDraggingRef`), 그리고 **reorder POST가 in-flight/큐에 있는 동안 폴링이 순서를 채택하지 않는** pending 가드. 가드가 걸린 폴링은 서버 순서를 버리되 membership은 `reconcileOrder`로 받아들인다. **reorder POST는 클라이언트에서 직렬화**한다(한 번에 하나, 큐에는 최신 순서만) — 두 POST가 별도 커넥션이라 서버가 옛 요청을 나중에 커밋해 잘못된 순서로 영속할 수 있다. **남는 transient 하나**: 커밋 전 서버를 읽었지만 POST가 정착한 뒤 도착하는 폴링은 한 번 스냅백할 수 있다 — 자기교정되는 클래스라 서버 revision을 도입하지 않는다. +- **영속은 open/close와 같은 경계**를 따른다: headless `serve`(`persist=true`)면 `catalog.paths()`가 `workspace.json`의 탭 순서로 저장되고, TUI 동반 실행에서는 세션 한정이다(그 파일의 주인이 TUI다). 저장 시 `persist_workspace`는 `ws.active`를 인덱스가 아니라 **이전 활성 path 기준으로 재매핑**한다. **한계**: `serve`에 `--repo`를 명시하면 그 인자가 시작 순서를 지배해 저장된 재정렬이 재시작 때 덮인다. 또 `catalog.reorder`와 이어지는 `persist_workspace`(파일 IO)는 한 트랜잭션이 아니라 두 기기가 밀리초 안에 동시에 재정렬하면 파일이 한 박자 뒤처질 수 있다(라이브 catalog는 항상 정확). ### 와이어 계약은 fixture로 고정한다 -`dto/` → `viewer-ui/api.fixture.json` → `api.contract.test.ts`. Rust DTO와 TS interface가 같은 -프로토콜을 손으로 두 번 적고 있어 한쪽만 고치면 화면이 조용히 빈 값으로 렌더된다. -`PROTOCOL_VERSION`은 **의도적인** 호환성 단절을 알릴 뿐 실수를 잡지 못한다. 그래서 서버가 모든 -페이로드의 예시를 fixture에 굽고(`UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`) 커밋한 뒤, TS -테스트가 그 JSON을 각 interface에 **대입**한다 — 검사는 `expect`가 아니라 타입 주석이 하고 -`npm run build`의 `tsc -b`에서 실패한다. Rust 쪽 변경은 fixture diff로, TS 쪽 미반영은 컴파일 -실패로 드러나는 **쌍**이 핵심이다. optional 필드는 있는 경우와 없는 경우를 모두 넣어 -`skip_serializing_if`가 멈춘 것도 보이게 한다. **필드 추가는 TS 쪽에서 잡히지 않는다** — 그건 Rust -fixture assertion이 잡는다. codegen(`ts-rs` 등)은 이 규모에서 얻는 게 fixture 한 장과 같아 쓰지 않는다. - -**`GET /api/repos`는 부트스트랩이다**(`ViewerBootstrapDto`). 저장소 목록에 `hot` 설정·`accent`· -`now_ms`가 얹히면서 이 응답은 "클라이언트가 렌더를 시작하기 전에 서버와 맞춰야 하는 것 전부"가 됐다. -서버 전역 값에 각각 엔드포인트를 주지 않는 이유는 **클라이언트가 이미 3초마다 이걸 폴링하기 -때문**이다. 반대로 `/api/status`에 얹지 않는 이유는 그쪽이 바이트 동일성으로 dedup되는 hot 스트림이라 -설정이 낄 자리가 아니기 때문이다. +`dto/` → `viewer-ui/api.fixture.json` → `api.contract.test.ts`. Rust DTO와 TS interface가 같은 프로토콜을 손으로 두 번 적고 있어 한쪽만 고치면 화면이 조용히 빈 값으로 렌더된다. `PROTOCOL_VERSION`은 **의도적인** 호환성 단절을 알릴 뿐 실수를 잡지 못한다. 그래서 서버가 모든 페이로드의 예시를 fixture에 굽고(`UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`) 커밋한 뒤, TS 테스트가 그 JSON을 각 interface에 **대입**한다 — 검사는 `expect`가 아니라 타입 주석이 하고 `npm run build`의 `tsc -b`에서 실패한다. Rust 쪽 변경은 fixture diff로, TS 쪽 미반영은 컴파일 실패로 드러나는 **쌍**이 핵심이다. optional 필드는 있는 경우와 없는 경우를 모두 넣어 `skip_serializing_if`가 멈춘 것도 보이게 한다. **필드 추가는 TS 쪽에서 잡히지 않는다** — 그건 Rust fixture assertion이 잡는다. codegen(`ts-rs` 등)은 이 규모에서 얻는 게 fixture 한 장과 같아 쓰지 않는다. + +**`GET /api/repos`는 부트스트랩이다**(`ViewerBootstrapDto`). 저장소 목록에 `hot` 설정·`accent`· `now_ms`가 얹히면서 이 응답은 "클라이언트가 렌더를 시작하기 전에 서버와 맞춰야 하는 것 전부"가 됐다. 서버 전역 값에 각각 엔드포인트를 주지 않는 이유는 **클라이언트가 이미 3초마다 이걸 폴링하기 때문**이다. 반대로 `/api/status`에 얹지 않는 이유는 그쪽이 바이트 동일성으로 dedup되는 hot 스트림이라 설정이 낄 자리가 아니기 때문이다. ### commit log 페이지네이션 (`/api/log`) -클라이언트가 목록 끝에 다다르면 다음 페이지를 요청한다(`IntersectionObserver` 센티넬 — TUI의 -prefetch에 대응). 페이지 크기는 `MAX_LOG_PAGE = 100`으로 TUI 기본값과 맞췄다. - -- **`skip`만으로 페이지를 나누지 않는다**: skip은 한 walk 안의 offset이라 페이지 사이에 커밋이 생기면 - 이후 offset이 전부 밀려 중복·누락이 생긴다 — 바로 아래 터미널 패널에서 커밋하는 것이 이 뷰어의 - 일상이다. 첫 응답이 walk 시작 커밋을 `head`로 실어 보내고 이후 요청은 `from=`로 고정한다. - `from`이 잘못된 oid면 HEAD로 조용히 넘어가지 않고 **400**이다. -- **커서 방식은 채택하지 않았다**: 병합 히스토리에서 특정 커밋부터 walk하면 그 커밋의 *조상만* - 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. -- **"더 있는가"는 한 페이지보다 1개 더 요청해 판정한다**: 정확히 한 페이지를 가져와 같은 수로 - capping하면 `truncated`가 참이 될 수 없어, 이전 구현은 항상 `false`를 보고했다. -- **`skip`에는 상한을 두지 않는다.** 순회량은 `skip + page`와 히스토리 길이 중 **작은 쪽**으로 이미 - 제한된다. 여기까지 온 클라이언트는 **이미 인증을 통과해 대화형 셸을 받은 상태**라, 그가 시킬 수 - 있는 일 중 revwalk 한 번은 가장 가벼운 축이다. 인증이 신뢰 경계이고 그 뒤에서 자원 사용을 다투는 - 것은 방어가 아니라 불편이다. **알려진 대가**: 페이지 i는 앞의 `i × MAX_LOG_PAGE`개를 다시 - 건너뛰므로 총비용이 히스토리 길이에 제곱으로 는다. anchor별 서버측 스냅샷 캐시로 없앨 수 있지만 - "요청마다 상태가 없다"는 이 서버의 성질을 포기해야 하고, 스크롤로 닿는 깊이에서 페이지당 비용이 - 밀리초 단위라 그 교환은 하지 않았다. -- **자동 페이징은 렌더된 행 수에 반응한다**(`visibleCommits.length`): `IntersectionObserver`는 - intersection *변화*만 보고하는데 페이지가 붙어도 센티넬이 제자리에 남을 수 있어 매 페이지마다 - 재관찰해야 한다. -- **필터가 걸린 동안에는 페이징을 멈춘다**: log 필터는 *로드된 것*을 좁히는 것이지 서버 검색이 - 아니므로 매치를 찾아 히스토리 전체를 걸어 들어가면 안 된다. "보이는 행 수" 기준만으로는 부족하다 — - 페이지마다 매치가 하나라도 있으면 계속 재무장된다. 센티넬 자리에는 "로드된 N개를 필터 중"이라는 - 행을 그린다. -- **페이지 실패는 `logDone`이 아니라 `logStalled`다**: 둘을 합치면 일시적 오류가 히스토리의 끝으로 - 보고되고, footer 에러는 다음 폴링에 지워져 흔적조차 남지 않는다. 실패 시 retry 행을 그린다. -- **열린 로그는 HEAD를 따라간다** — status SSE가 나르는 `head`가 움직이면 fresh 첫 페이지를 받아 - 캐시에 접는다(`lib/logRefresh.ts`, TUI `apply_refresh_page`와 같은 규칙): 이전 head가 fresh - 페이지에 남아 있고 그 아래가 캐시와 일치하면 새 커밋만 위에 붙이고(스크롤·drill-down 유지), - 아니면(rebase·amend) fresh 페이지로 교체한다. prepend가 페이징을 깨지 않으려면 합쳐진 목록이 - 새 walk의 prefix여야 하는데, 판별 조건이 증명하는 것은 fresh 페이지가 보여주는 구간까지다 — - 페이지 경계 아래는 안 움직였다고 신뢰하며, 병합의 side-branch 커밋이 경계 아래로 날짜순 정렬되면 - 그 신뢰가 깨져 깊은 페이지가 그 구간을 건너뛴다. TUI의 규칙이 거는 것과 같은 베팅이고(변경 없는 - 커밋 100개 아래의 rewrite여야 진다), 다음 탭 진입이 처음부터 다시 walk한다. refresh의 트리거는 - "이전 head" 기준선이 아니라 **캐시가 walk된 head와 status head의 불일치**다 — 기준선 방식은 아직 - 아무것도 로드되지 않았을 때의 이동(빈 저장소의 첫 커밋, 초기 로드 비행 중의 커밋)을 잃는다. - head는 3값이다: status 미도착(`undefined`)은 침묵이라 아무것도 안 하고, status가 head 없이 온 - 것(`null`)은 unborn HEAD의 보고라 목록을 비우는 refresh를 만든다(detached HEAD는 커밋 oid를 - 보고하므로 여기 해당하지 않는다). - 첫 페이지가 착지할 때 status head와 어긋나 있으면 그 자리에서 refresh하고, refresh는 진행 중인 - 페이지 요청을 세대 올림으로 무효화한다(TUI의 fetch worker cancel과 같은 자리). refresh 실패는 - 불일치를 남겨 두므로 retry 행이 `logStalled`를 지우면 같은 비교가 다시 refresh를 만든다. - 교체로 drill-down의 커밋이 - 목록에서 사라지면 drill-down은 자기 back 버튼과 같은 방식으로 닫힌다 — pane까지, pane이 보여주던 - 것이 그 커밋의 파일이므로. 탭을 떠나면 페이지가 버려지는 것은 그대로다. +클라이언트가 목록 끝에 다다르면 다음 페이지를 요청한다(`IntersectionObserver` 센티넬 — TUI의 prefetch에 대응). 페이지 크기는 `MAX_LOG_PAGE = 100`으로 TUI 기본값과 맞췄다. + +- **`skip`만으로 페이지를 나누지 않는다**: skip은 한 walk 안의 offset이라 페이지 사이에 커밋이 생기면 이후 offset이 전부 밀려 중복·누락이 생긴다 — 바로 아래 터미널 패널에서 커밋하는 것이 이 뷰어의 일상이다. 첫 응답이 walk 시작 커밋을 `head`로 실어 보내고 이후 요청은 `from=`로 고정한다. `from`이 잘못된 oid면 HEAD로 조용히 넘어가지 않고 **400**이다. +- **커서 방식은 채택하지 않았다**: 병합 히스토리에서 특정 커밋부터 walk하면 그 커밋의 *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. +- **"더 있는가"는 한 페이지보다 1개 더 요청해 판정한다**: 정확히 한 페이지를 가져와 같은 수로 capping하면 `truncated`가 참이 될 수 없어, 이전 구현은 항상 `false`를 보고했다. +- **`skip`에는 상한을 두지 않는다.** 순회량은 `skip + page`와 히스토리 길이 중 **작은 쪽**으로 이미 제한된다. 여기까지 온 클라이언트는 **이미 인증을 통과해 대화형 셸을 받은 상태**라, 그가 시킬 수 있는 일 중 revwalk 한 번은 가장 가벼운 축이다. 인증이 신뢰 경계이고 그 뒤에서 자원 사용을 다투는 것은 방어가 아니라 불편이다. **알려진 대가**: 페이지 i는 앞의 `i × MAX_LOG_PAGE`개를 다시 건너뛰므로 총비용이 히스토리 길이에 제곱으로 는다. anchor별 서버측 스냅샷 캐시로 없앨 수 있지만 "요청마다 상태가 없다"는 이 서버의 성질을 포기해야 하고, 스크롤로 닿는 깊이에서 페이지당 비용이 밀리초 단위라 그 교환은 하지 않았다. +- **자동 페이징은 렌더된 행 수에 반응한다**(`visibleCommits.length`): `IntersectionObserver`는 intersection *변화*만 보고하는데 페이지가 붙어도 센티넬이 제자리에 남을 수 있어 매 페이지마다 재관찰해야 한다. +- **필터가 걸린 동안에는 페이징을 멈춘다**: log 필터는 *로드된 것*을 좁히는 것이지 서버 검색이 아니므로 매치를 찾아 히스토리 전체를 걸어 들어가면 안 된다. "보이는 행 수" 기준만으로는 부족하다 — 페이지마다 매치가 하나라도 있으면 계속 재무장된다. 센티넬 자리에는 "로드된 N개를 필터 중"이라는 행을 그린다. +- **페이지 실패는 `logDone`이 아니라 `logStalled`다**: 둘을 합치면 일시적 오류가 히스토리의 끝으로 보고되고, footer 에러는 다음 폴링에 지워져 흔적조차 남지 않는다. 실패 시 retry 행을 그린다. +- **열린 로그는 HEAD를 따라간다** — status SSE가 나르는 `head`가 움직이면 fresh 첫 페이지를 받아 캐시에 접는다(`lib/logRefresh.ts`, TUI `apply_refresh_page`와 같은 규칙): 이전 head가 fresh 페이지에 남아 있고 그 아래가 캐시와 일치하면 새 커밋만 위에 붙이고(스크롤·drill-down 유지), 아니면(rebase·amend) fresh 페이지로 교체한다. prepend가 페이징을 깨지 않으려면 합쳐진 목록이 새 walk의 prefix여야 하는데, 판별 조건이 증명하는 것은 fresh 페이지가 보여주는 구간까지다 — 페이지 경계 아래는 안 움직였다고 신뢰하며, 병합의 side-branch 커밋이 경계 아래로 날짜순 정렬되면 그 신뢰가 깨져 깊은 페이지가 그 구간을 건너뛴다. TUI의 규칙이 거는 것과 같은 베팅이고(변경 없는 커밋 100개 아래의 rewrite여야 진다), 다음 탭 진입이 처음부터 다시 walk한다. refresh의 트리거는 "이전 head" 기준선이 아니라 **캐시가 walk된 head와 status head의 불일치**다 — 기준선 방식은 아직 아무것도 로드되지 않았을 때의 이동(빈 저장소의 첫 커밋, 초기 로드 비행 중의 커밋)을 잃는다. head는 3값이다: status 미도착(`undefined`)은 침묵이라 아무것도 안 하고, status가 head 없이 온 것(`null`)은 unborn HEAD의 보고라 목록을 비우는 refresh를 만든다(detached HEAD는 커밋 oid를 보고하므로 여기 해당하지 않는다). 첫 페이지가 착지할 때 status head와 어긋나 있으면 그 자리에서 refresh하고, refresh는 진행 중인 페이지 요청을 세대 올림으로 무효화한다(TUI의 fetch worker cancel과 같은 자리). refresh 실패는 불일치를 남겨 두므로 retry 행이 `logStalled`를 지우면 같은 비교가 다시 refresh를 만든다. 교체로 drill-down의 커밋이 목록에서 사라지면 drill-down은 자기 back 버튼과 같은 방식으로 닫힌다 — pane까지, pane이 보여주던 것이 그 커밋의 파일이므로. 탭을 떠나면 페이지가 버려지는 것은 그대로다. ## 프론트엔드 (`viewer-ui/`) -React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6, 마크다운은 react-markdown -(+remark-gfm, rehype-highlight). shadcn/ui는 쓰지 않는다 — 기본 톤이 TUI 밀도와 맞지 않아 덮어쓸 -것이 더 많았다. `dist/`를 커밋해 `cargo install`에 Node를 요구하지 않는다(build.rs에서 npm을 부르면 -Node 없는 설치가 전부 깨진다). CI가 재빌드해 커밋된 번들과 다르면 실패시킨다. - -`viewer-ui/src`는 화면 조립과 재사용 단위를 분리한다. `pages/`는 화면 조립, `components/`는 재사용 -UI, `hooks/`는 UI·터미널·저장소 상태, `lib/`는 API 이외의 순수 도메인/레이아웃 유틸리티, `api/`는 -서버 wire 계약과 HTTP 클라이언트, `styles/`는 전역 스타일이다. `pages/App.tsx`는 조립만 하고 -`useAppViewModel`이 인증·프로젝트·clone을, `useRepoWorkspace`가 선택한 저장소의 status/log/pane을 -소유한다. 서로만 주고받는 ref들을 App에 늘어놓으면 그 handshake가 조립 코드에 섞여 하나를 빠뜨렸을 -때 원인이 보이지 않는다. `RepoShell`은 flat prop bag 대신 repository/sidebar/filePane/layout 계약을 -받는다. -터미널 WebSocket 메시지는 `api/terminal.ts`가 decode/encode하는 단일 경계를 두고, 각 terminal hook은 -검증된 discriminated union만 처리한다. wire 문자열을 hook마다 다시 해석하거나 조립하지 않는다. - -**테스트는 두 층이다.** `src/lib`의 순수 함수는 vitest 기본 환경(`node`)에서, React 훅은 -`@testing-library/react`의 `renderHook`으로 DOM 환경에서 돈다 — DOM이 필요한 테스트 파일만 첫 줄 -`// @vitest-environment happy-dom`로 스스로 선언하고, 나머지는 빠른 node에 남는다. 훅 층을 -들인 계기는 view 기억 기능이다: 결함이 전부 훅 배선(복원↔기록 순서, 프로젝트 전환 레이스)에 -있었는데 그 층에 테스트가 없어 리뷰로만 검증됐다. 환경은 **happy-dom** — jsdom보다 수 배 빠르고 -Vitest 쪽 권장이며, 결정적으로 `window.matchMedia`를 구현한다(jsdom은 없어서 mock이 필요한데 -`useTermKeyBar`·`termFont`가 그걸 읽는다). fidelity가 모자란 테스트는 jsdom을 들여 같은 파일 단위 -방식으로 옮기면 된다. `@testing-library/react` 16은 `@testing-library/dom`을 peer로 요구해 함께 -설치했다; `user-event`·`jest-dom`은 훅 테스트에 불필요해 컴포넌트 테스트를 시작할 때로 미뤘다. - -**큰 diff와 raw file은 viewport만 DOM에 둔다**(`lib/virtualWindow.ts`, -`components/Virtual*`). 200행 이하는 브라우저의 native selection·find·접근성 트리를 그대로 얻도록 -기존 전체 DOM 경로를 쓰고, 그보다 크면 20px 고정 행과 앞뒤 12행 overscan으로 windowing한다. 전체 -높이는 spacer가 보존하므로 scrollbar와 저장한 `scrollTop`은 원본 행 수를 계속 나타낸다. 파일 anchor는 -같은 행 높이로 직접 계산하고, diff의 모든 렌더 행은 자기 `data-hunk`를 가져 header가 viewport 밖이어도 -whole-file 전환이 현재 hunk를 찾는다. Split은 넓은 화면에서 old/new 한 쌍을 같은 virtual row로 -렌더해 세로 정렬을 보존하고, 좁은 화면에서는 hunk별 old 전체 뒤에 new 전체가 오도록 별도 row model을 -쓴다. 20k fixture가 DOM 행 수를 계약으로 고정하고 initial/scroll/split 측정치를 함께 기록한다. - -**렌더 실패가 페이지를 가져가지 않게 한다**(`components/feedback/ErrorBoundary.tsx`, -`lib/chunkError.ts`). boundary가 하나도 없으면 React는 어떤 렌더 에러에도 트리 전체를 unmount하고, -보는 사람 입장에서 그것은 **서버가 죽은 것과 구분되지 않는다.** 실제로 사라진 청크가 그 모양으로 -왔다: on-demand 청크(markdown 렌더러·HTML preview·터미널 패널)는 content hash가 붙은 이름으로 -받으므로, 빌드 이전에 열린 탭은 그 빌드가 지운 이름을 요구한다. debug에서는 `dist`를 디스크에서 -읽으므로 재빌드만으로 그렇게 되고, release에서는 도는 프로세스가 자기 안의 번들을 계속 내주므로 -`nightcrow update` **뒤 세션을 재시작해** 새 프로세스가 뜬 다음이다. - -- **그 자리에서 재시도할 수 없다.** HTML spec이 실패한 module fetch를 캐시하게 하므로(스크립트가 - 두 번 도는 것을 막기 위해) 같은 import는 그 페이지가 사는 동안 계속 같은 실패를 준다. 복구는 - 리로드뿐이고, 그래서 fallback이 내미는 것도 재시도가 아니라 리로드다. -- **판정은 `lib/chunkError.ts`의 순수 함수가 한다.** 로직을 테스트하기 가장 쉬운 곳(순수 함수)에 - 두고 boundary는 그것을 부르는 껍데기로 남긴다 — 훅 테스트 환경이 생긴 지금도 이 배치가 낫다. - 판정은 **좁게** 잡는다 — 알아보지 못한 것은 일반 실패로 보고한다. 보고 있던 컴포넌트의 버그에 - 리로드를 권하는 것은 고쳐주는 것이 없으면서 읽던 자리만 잃게 한다. -- **왜 실패했는지는 알 수 없고, 아는 척하지 않는다.** 지워진 청크와 닿지 않는 서버는 어느 - 엔진에서도 같은 맨 `TypeError`로 온다 — SSH 터널 너머의 뷰어는 빌드보다 서버를 먼저 잃는 쪽이 - 드물지 않다. 그래서 함수 이름이 `isChunkLoadError`(원인이 아니라 사실)이고, 문구도 둘 다를 - **가능성 순서대로** 말한 뒤 리로드에 판정을 맡긴다. "새 버전이 배포됐다"고 단정하면 서버가 - 죽은 순간에 사람을 엉뚱한 데로 보낸다. -- **fallback은 자기가 대신 선 자식의 가시성을 물려받아야 한다.** 자식의 className은 상속되지 - 않는데, 터미널 패널은 `md` 미만에서 선택된 뷰일 때만 화면에 있다. 그대로 두면 **실패가 그 - 패널을 등장시키는 경로**가 된다 — 고르지도 않은 영역이 에러 카드로 나타난다. boundary가 - `className`을 받아 fallback에 그대로 걸고, display 클래스를 fallback 안에 박아두지 않는다 - (`hidden md:flex`와 `flex`가 붙으면 승자는 호출부 의도가 아니라 CSS source order가 된다). -- **preview boundary는 파일로 key를 잡는다.** 렌더러가 서로 다른 청크라 하나를 잃은 것이 다른 - 하나에 대해 말해주는 바가 없는데, boundary는 remount 전까지 에러 상태를 붙들기 때문이다. -- Vite의 `vite:preloadError`를 따로 듣지 않는다. `preventDefault`하지 않으면 어차피 throw되어 - import promise를 reject시키므로 boundary가 두 경로를 다 받는다 — 메커니즘을 하나로 둔다. - -**그래도 위는 사후다. 갱신은 미리 알린다**(`web/viewer/assets.rs`, `lib/viewerBuild.ts`). 청크 -실패는 탭이 하필 그 청크를 요구할 때만 오고, preview도 새 패널도 열지 않는 탭은 **영영 모른 채** -옛 번들로 돈다 — 배포된 수정이 그 화면에만 도착하지 않는다. 그래서 서버가 자기 빌드를 이름 붙여 -알린다. - -- **빌드 id는 `index.html`의 sha256 앞 4바이트다.** 코드는 전부 content hash가 파일명에 박혀 - 있고 셸이 그 이름들을 부르므로, 청크나 스타일시트의 어떤 변화든 셸을 바꾼다. 고정 이름으로 - 복사되는 `public/`은 여기 들어오지 않는다 — 아무도 import하지 않는 파일은 **도는 코드를 낡게 - 만들 수 없고**, 이 비교가 묻는 것은 그것뿐이다. 매 호출마다 다시 읽는다 — - `dist`를 디스크에서 읽는 debug 서버에서 **도는 데몬 밑의 재빌드**가 바로 이 기능이 잡으려는 - 경우다. 빌드를 구분하는 값이지 인증하는 값이 아니라 4바이트로 충분하다. -- **비교의 한쪽은 문서에 도장으로 박는다.** 서버가 셸을 내줄 때 ``를 - head에 끼워 넣는다. 페이지가 "내가 어느 빌드냐"를 **응답에서 추론하면 틀린다**: 로그인 화면에 - 머무는 동안 배포되면 첫 성공 응답이 이미 새 빌드라, 옛 번들을 돌리면서 새 빌드를 자기 것으로 - 기록하고 영영 알리지 않는다. 도장은 **박히는 파일에서 유도되므로 그 파일의 일부가 될 수 없다** — - id는 저장된 바이트의 해시이고, 내주는 바이트는 거기에 태그가 더해진 것이다. -- **다른 한쪽은 이미 도는 폴링에 얹는다**(`ViewerBootstrap.viewer_build`). 3초 폴링이 이미 세션 - 전역을 나르는 통로라 엔드포인트를 새로 열지 않는다. -- **자동 리로드는 하지 않는다.** 터미널에 입력 중인 탭을 페이지가 스스로 날리는 것은 한 빌드 뒤진 - 것보다 나쁘다. 알림은 sticky 토스트로 남고 Reload 버튼을 함께 낸다 — sticky는 사건이 아니라 - 아직 참인 **상태**를 말하므로, 타임아웃으로도 뒤이은 에러 토스트에도 밀려나지 않는다 - (`lib/toast.ts`의 `trim`). - -**pane 안의 프로그램이 클립보드를 채운다**(`lib/osc52.ts`, `lib/paneClipboard.ts`). PTY 건너편의 -프로그램에는 읽는 사람에게 닿는 클립보드가 없다 — Claude Code의 `/copy`가 함께 부르는 `pbcopy`는 -세션을 호스팅하는 기계에 쓰므로, 다른 데서 연 뷰어에서는 아무도 꺼낼 수 없는 곳에 들어간다. -OSC 52는 출력과 함께 흘러 **출력이 보이는 곳에서 끝나는** 유일한 경로다. - -- **처리하지 않으면 조용히 사라진다.** 프로그램은 시퀀스를 내보낸 것과 같은 분기에서 "Copied to - clipboard (63 characters)"를 찍으므로, 성공 여부를 되묻지 않는다. 즉 무시하는 터미널은 읽는 - 사람에게 **확인 문구와 그대로인 클립보드**를 남긴다. TUI는 같은 기계라 `pbcopy`로 멀쩡히 - 되므로, 이것은 viewer에만 뚫린 구멍이었다. -- **`@xterm/addon-clipboard`를 쓰지 않는다.** 그 addon이 하는 일은 OSC 핸들러 등록·selection - 파싱·base64 디코드·provider 호출인데, 기본 provider가 `navigator.clipboard`뿐이라 아래 이유로 - 어차피 우리 provider가 필요하다. 남는 것을 위해 `js-base64` transitive dependency와 xterm 6 - peer 미선언을 떠안는 대신 `parser.registerOscHandler(52, …)`로 직접 받는다. 디코드는 `atob` + - `TextDecoder`로 되고, **파싱이 순수 함수로 남아 vitest 기본 환경(node)에서 검증된다** — - addon을 썼으면 테스트할 수 있는 것은 provider 껍데기뿐이었다. -- **읽기 질의(`c;?`)에는 답하지 않는다.** 답하면 읽는 사람이 마지막으로 복사한 것 — 비밀번호, - 토큰 — 이 **터미널 입력으로** pane 안의 프로그램에게 간다. **쓰기 허용은 귀결이 아니라 거래다**: - "pane에 닿으면 이미 호스트 셸 권한"에서 따라 나오지 않는다. 덮이는 클립보드는 pane이 도는 - 기계가 아니라 **보고 있는 기기의 것**이고, 그 사람이 아무 데서나 마지막으로 복사한 것을 담고 - 있다. 얻는 것은 pane의 복사가 도착한다는 기능 자체고, 내주는 것은 프로그램이 보지 않고 - 붙여넣을 클립보드를 바꿀 수 있다는 것이다. 이 쪽은 어느 터미널 에뮬레이터나 같게 정하고, - 갈리는 것은 읽기다 — 여기서는 엄격한 쪽을 택했다. 빈 데이터(클립보드 비우기)도 같이 버린다. -- **selection 매핑은 명세를 그대로 읽지 않는다.** 페이지의 클립보드는 하나뿐이라 `c`와 `s`, - 그리고 생략(명세상 `s0`)이 모두 그 하나로 간다 — 의도된 손실 매핑이다. 대응물이 없는 - `p`/`q`/cut buffer는 그리로 **넓혀 주지 않고 버린다**: 가운데 클릭 버퍼를 달라고 한 프로그램이 - 읽는 사람의 클립보드를 덮어도 좋다고 한 것은 아니다. 정의되지 않은 글자가 섞이면 selection이 - 아니므로 거절한다(`cX`의 `c` 하나를 근거로 삼지 않는다). -- **쓸 수 있는지는 예측하지 않고 시도해서 안다.** `navigator.clipboard`는 보안 컨텍스트에만 있어 - 평문 `http://`로 연 뷰어(Tailscale 주소, LAN IP — 원격에서 여는 방식의 대부분)에는 아예 없다. - 어느 규칙이 걸리는지 맞히는 대신 써 보고 실패를 읽는다. **그래서 평문에서는 fallback이 드문 - 경로가 아니라 유일한 경로다** — 숨은 textarea를 만들어 선택하고 `execCommand("copy")`를 부르며, - 브라우저는 문서에 포커스가 있으면 gesture 없이도 대개 허용한다. 버튼이 한 번도 안 뜨는 이유가 - 이것이다. **대가는 그 순간의 blur다**: 선택에는 포커스가 필요하고 `execCommand`에는 선택이 - 필요하다. 포커스는 되돌리므로 이미 확정된 입력은 무사하지만, 그 찰나에 조립 중이던 IME 음절은 - 잃는다 — 다른 pane의 프로그램이 하필 그때 복사하면 한글 한 글자가 날아간다. 여기서 피할 방법이 - 없어 한계로 남긴다. -- **거절당하면 없는 것이 press이므로 버튼으로 내민다.** sticky 토스트의 Copy가 그 press다. - 누르면 **그 시점에 걸려 있는 텍스트**를 복사하고, 그것이 건너갔을 때만 토스트를 내린다 — 쓰기가 - 즉시가 아니라, 이전 press가 진행 중일 때 새 복사가 들어오면 토스트가 가리키는 대상이 바뀌기 - 때문이다. 옛 결과로 내리면 **한 번도 건너간 적 없는 텍스트의 알림을 치우게 된다**. 누른 뒤에도 - 실패하면 토스트는 그대로 서 있는다 — 건너가지 않은 복사는 사건이 아니라 아직 참인 상태다. +React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6, 마크다운은 react-markdown (+remark-gfm, rehype-highlight). shadcn/ui는 쓰지 않는다 — 기본 톤이 TUI 밀도와 맞지 않아 덮어쓸 것이 더 많았다. `dist/`를 커밋해 `cargo install`에 Node를 요구하지 않는다(build.rs에서 npm을 부르면 Node 없는 설치가 전부 깨진다). CI가 재빌드해 커밋된 번들과 다르면 실패시킨다. + +`viewer-ui/src`는 화면 조립과 재사용 단위를 분리한다. `pages/`는 화면 조립, `components/`는 재사용 UI, `hooks/`는 UI·터미널·저장소 상태, `lib/`는 API 이외의 순수 도메인/레이아웃 유틸리티, `api/`는 서버 wire 계약과 HTTP 클라이언트, `styles/`는 전역 스타일이다. `pages/App.tsx`는 조립만 하고 `useAppViewModel`이 인증·프로젝트·clone을, `useRepoWorkspace`가 선택한 저장소의 status/log/pane을 소유한다. 서로만 주고받는 ref들을 App에 늘어놓으면 그 handshake가 조립 코드에 섞여 하나를 빠뜨렸을 때 원인이 보이지 않는다. `RepoShell`은 flat prop bag 대신 repository/sidebar/filePane/layout 계약을 받는다. 터미널 WebSocket 메시지는 `api/terminal.ts`가 decode/encode하는 단일 경계를 두고, 각 terminal hook은 검증된 discriminated union만 처리한다. wire 문자열을 hook마다 다시 해석하거나 조립하지 않는다. + +**테스트는 두 층이다.** `src/lib`의 순수 함수는 vitest 기본 환경(`node`)에서, React 훅은 `@testing-library/react`의 `renderHook`으로 DOM 환경에서 돈다 — DOM이 필요한 테스트 파일만 첫 줄 `// @vitest-environment happy-dom`로 스스로 선언하고, 나머지는 빠른 node에 남는다. 훅 층을 들인 계기는 view 기억 기능이다: 결함이 전부 훅 배선(복원↔기록 순서, 프로젝트 전환 레이스)에 있었는데 그 층에 테스트가 없어 리뷰로만 검증됐다. 환경은 **happy-dom** — jsdom보다 수 배 빠르고 Vitest 쪽 권장이며, 결정적으로 `window.matchMedia`를 구현한다(jsdom은 없어서 mock이 필요한데 `useTermKeyBar`·`termFont`가 그걸 읽는다). fidelity가 모자란 테스트는 jsdom을 들여 같은 파일 단위 방식으로 옮기면 된다. `@testing-library/react` 16은 `@testing-library/dom`을 peer로 요구해 함께 설치했다; `user-event`·`jest-dom`은 훅 테스트에 불필요해 컴포넌트 테스트를 시작할 때로 미뤘다. + +**큰 diff와 raw file은 viewport만 DOM에 둔다**(`lib/virtualWindow.ts`, `components/Virtual*`). 200행 이하는 브라우저의 native selection·find·접근성 트리를 그대로 얻도록 기존 전체 DOM 경로를 쓰고, 그보다 크면 20px 고정 행과 앞뒤 12행 overscan으로 windowing한다. 전체 높이는 spacer가 보존하므로 scrollbar와 저장한 `scrollTop`은 원본 행 수를 계속 나타낸다. 파일 anchor는 같은 행 높이로 직접 계산하고, diff의 모든 렌더 행은 자기 `data-hunk`를 가져 header가 viewport 밖이어도 whole-file 전환이 현재 hunk를 찾는다. Split은 넓은 화면에서 old/new 한 쌍을 같은 virtual row로 렌더해 세로 정렬을 보존하고, 좁은 화면에서는 hunk별 old 전체 뒤에 new 전체가 오도록 별도 row model을 쓴다. 20k fixture가 DOM 행 수를 계약으로 고정하고 initial/scroll/split 측정치를 함께 기록한다. + +**렌더 실패가 페이지를 가져가지 않게 한다**(`components/feedback/ErrorBoundary.tsx`, `lib/chunkError.ts`). boundary가 하나도 없으면 React는 어떤 렌더 에러에도 트리 전체를 unmount하고, 보는 사람 입장에서 그것은 **서버가 죽은 것과 구분되지 않는다.** 실제로 사라진 청크가 그 모양으로 왔다: on-demand 청크(markdown 렌더러·HTML preview·터미널 패널)는 content hash가 붙은 이름으로 받으므로, 빌드 이전에 열린 탭은 그 빌드가 지운 이름을 요구한다. debug에서는 `dist`를 디스크에서 읽으므로 재빌드만으로 그렇게 되고, release에서는 도는 프로세스가 자기 안의 번들을 계속 내주므로 `nightcrow update` **뒤 세션을 재시작해** 새 프로세스가 뜬 다음이다. + +- **그 자리에서 재시도할 수 없다.** HTML spec이 실패한 module fetch를 캐시하게 하므로(스크립트가 두 번 도는 것을 막기 위해) 같은 import는 그 페이지가 사는 동안 계속 같은 실패를 준다. 복구는 리로드뿐이고, 그래서 fallback이 내미는 것도 재시도가 아니라 리로드다. +- **판정은 `lib/chunkError.ts`의 순수 함수가 한다.** 로직을 테스트하기 가장 쉬운 곳(순수 함수)에 두고 boundary는 그것을 부르는 껍데기로 남긴다 — 훅 테스트 환경이 생긴 지금도 이 배치가 낫다. 판정은 **좁게** 잡는다 — 알아보지 못한 것은 일반 실패로 보고한다. 보고 있던 컴포넌트의 버그에 리로드를 권하는 것은 고쳐주는 것이 없으면서 읽던 자리만 잃게 한다. +- **왜 실패했는지는 알 수 없고, 아는 척하지 않는다.** 지워진 청크와 닿지 않는 서버는 어느 엔진에서도 같은 맨 `TypeError`로 온다 — SSH 터널 너머의 뷰어는 빌드보다 서버를 먼저 잃는 쪽이 드물지 않다. 그래서 함수 이름이 `isChunkLoadError`(원인이 아니라 사실)이고, 문구도 둘 다를 **가능성 순서대로** 말한 뒤 리로드에 판정을 맡긴다. "새 버전이 배포됐다"고 단정하면 서버가 죽은 순간에 사람을 엉뚱한 데로 보낸다. +- **fallback은 자기가 대신 선 자식의 가시성을 물려받아야 한다.** 자식의 className은 상속되지 않는데, 터미널 패널은 `md` 미만에서 선택된 뷰일 때만 화면에 있다. 그대로 두면 **실패가 그 패널을 등장시키는 경로**가 된다 — 고르지도 않은 영역이 에러 카드로 나타난다. boundary가 `className`을 받아 fallback에 그대로 걸고, display 클래스를 fallback 안에 박아두지 않는다 (`hidden md:flex`와 `flex`가 붙으면 승자는 호출부 의도가 아니라 CSS source order가 된다). +- **preview boundary는 파일로 key를 잡는다.** 렌더러가 서로 다른 청크라 하나를 잃은 것이 다른 하나에 대해 말해주는 바가 없는데, boundary는 remount 전까지 에러 상태를 붙들기 때문이다. +- Vite의 `vite:preloadError`를 따로 듣지 않는다. `preventDefault`하지 않으면 어차피 throw되어 import promise를 reject시키므로 boundary가 두 경로를 다 받는다 — 메커니즘을 하나로 둔다. + +**그래도 위는 사후다. 갱신은 미리 알린다**(`web/viewer/assets.rs`, `lib/viewerBuild.ts`). 청크 실패는 탭이 하필 그 청크를 요구할 때만 오고, preview도 새 패널도 열지 않는 탭은 **영영 모른 채** 옛 번들로 돈다 — 배포된 수정이 그 화면에만 도착하지 않는다. 그래서 서버가 자기 빌드를 이름 붙여 알린다. + +- **빌드 id는 `index.html`의 sha256 앞 4바이트다.** 코드는 전부 content hash가 파일명에 박혀 있고 셸이 그 이름들을 부르므로, 청크나 스타일시트의 어떤 변화든 셸을 바꾼다. 고정 이름으로 복사되는 `public/`은 여기 들어오지 않는다 — 아무도 import하지 않는 파일은 **도는 코드를 낡게 만들 수 없고**, 이 비교가 묻는 것은 그것뿐이다. 매 호출마다 다시 읽는다 — `dist`를 디스크에서 읽는 debug 서버에서 **도는 데몬 밑의 재빌드**가 바로 이 기능이 잡으려는 경우다. 빌드를 구분하는 값이지 인증하는 값이 아니라 4바이트로 충분하다. +- **비교의 한쪽은 문서에 도장으로 박는다.** 서버가 셸을 내줄 때 ``를 head에 끼워 넣는다. 페이지가 "내가 어느 빌드냐"를 **응답에서 추론하면 틀린다**: 로그인 화면에 머무는 동안 배포되면 첫 성공 응답이 이미 새 빌드라, 옛 번들을 돌리면서 새 빌드를 자기 것으로 기록하고 영영 알리지 않는다. 도장은 **박히는 파일에서 유도되므로 그 파일의 일부가 될 수 없다** — id는 저장된 바이트의 해시이고, 내주는 바이트는 거기에 태그가 더해진 것이다. +- **다른 한쪽은 이미 도는 폴링에 얹는다**(`ViewerBootstrap.viewer_build`). 3초 폴링이 이미 세션 전역을 나르는 통로라 엔드포인트를 새로 열지 않는다. +- **자동 리로드는 하지 않는다.** 터미널에 입력 중인 탭을 페이지가 스스로 날리는 것은 한 빌드 뒤진 것보다 나쁘다. 알림은 sticky 토스트로 남고 Reload 버튼을 함께 낸다 — sticky는 사건이 아니라 아직 참인 **상태**를 말하므로, 타임아웃으로도 뒤이은 에러 토스트에도 밀려나지 않는다 (`lib/toast.ts`의 `trim`). + +**pane 안의 프로그램이 클립보드를 채운다**(`lib/osc52.ts`, `lib/paneClipboard.ts`). PTY 건너편의 프로그램에는 읽는 사람에게 닿는 클립보드가 없다 — Claude Code의 `/copy`가 함께 부르는 `pbcopy`는 세션을 호스팅하는 기계에 쓰므로, 다른 데서 연 뷰어에서는 아무도 꺼낼 수 없는 곳에 들어간다. OSC 52는 출력과 함께 흘러 **출력이 보이는 곳에서 끝나는** 유일한 경로다. + +- **처리하지 않으면 조용히 사라진다.** 프로그램은 시퀀스를 내보낸 것과 같은 분기에서 "Copied to clipboard (63 characters)"를 찍으므로, 성공 여부를 되묻지 않는다. 즉 무시하는 터미널은 읽는 사람에게 **확인 문구와 그대로인 클립보드**를 남긴다. TUI는 같은 기계라 `pbcopy`로 멀쩡히 되므로, 이것은 viewer에만 뚫린 구멍이었다. +- **`@xterm/addon-clipboard`를 쓰지 않는다.** 그 addon이 하는 일은 OSC 핸들러 등록·selection 파싱·base64 디코드·provider 호출인데, 기본 provider가 `navigator.clipboard`뿐이라 아래 이유로 어차피 우리 provider가 필요하다. 남는 것을 위해 `js-base64` transitive dependency와 xterm 6 peer 미선언을 떠안는 대신 `parser.registerOscHandler(52, …)`로 직접 받는다. 디코드는 `atob` + `TextDecoder`로 되고, **파싱이 순수 함수로 남아 vitest 기본 환경(node)에서 검증된다** — addon을 썼으면 테스트할 수 있는 것은 provider 껍데기뿐이었다. +- **읽기 질의(`c;?`)에는 답하지 않는다.** 답하면 읽는 사람이 마지막으로 복사한 것 — 비밀번호, 토큰 — 이 **터미널 입력으로** pane 안의 프로그램에게 간다. **쓰기 허용은 귀결이 아니라 거래다**: "pane에 닿으면 이미 호스트 셸 권한"에서 따라 나오지 않는다. 덮이는 클립보드는 pane이 도는 기계가 아니라 **보고 있는 기기의 것**이고, 그 사람이 아무 데서나 마지막으로 복사한 것을 담고 있다. 얻는 것은 pane의 복사가 도착한다는 기능 자체고, 내주는 것은 프로그램이 보지 않고 붙여넣을 클립보드를 바꿀 수 있다는 것이다. 이 쪽은 어느 터미널 에뮬레이터나 같게 정하고, 갈리는 것은 읽기다 — 여기서는 엄격한 쪽을 택했다. 빈 데이터(클립보드 비우기)도 같이 버린다. +- **selection 매핑은 명세를 그대로 읽지 않는다.** 페이지의 클립보드는 하나뿐이라 `c`와 `s`, 그리고 생략(명세상 `s0`)이 모두 그 하나로 간다 — 의도된 손실 매핑이다. 대응물이 없는 `p`/`q`/cut buffer는 그리로 **넓혀 주지 않고 버린다**: 가운데 클릭 버퍼를 달라고 한 프로그램이 읽는 사람의 클립보드를 덮어도 좋다고 한 것은 아니다. 정의되지 않은 글자가 섞이면 selection이 아니므로 거절한다(`cX`의 `c` 하나를 근거로 삼지 않는다). +- **쓸 수 있는지는 예측하지 않고 시도해서 안다.** `navigator.clipboard`는 보안 컨텍스트에만 있어 평문 `http://`로 연 뷰어(Tailscale 주소, LAN IP — 원격에서 여는 방식의 대부분)에는 아예 없다. 어느 규칙이 걸리는지 맞히는 대신 써 보고 실패를 읽는다. **그래서 평문에서는 fallback이 드문 경로가 아니라 유일한 경로다** — 숨은 textarea를 만들어 선택하고 `execCommand("copy")`를 부르며, 브라우저는 문서에 포커스가 있으면 gesture 없이도 대개 허용한다. 버튼이 한 번도 안 뜨는 이유가 이것이다. **대가는 그 순간의 blur다**: 선택에는 포커스가 필요하고 `execCommand`에는 선택이 필요하다. 포커스는 되돌리므로 이미 확정된 입력은 무사하지만, 그 찰나에 조립 중이던 IME 음절은 잃는다 — 다른 pane의 프로그램이 하필 그때 복사하면 한글 한 글자가 날아간다. 여기서 피할 방법이 없어 한계로 남긴다. +- **거절당하면 없는 것이 press이므로 버튼으로 내민다.** sticky 토스트의 Copy가 그 press다. 누르면 **그 시점에 걸려 있는 텍스트**를 복사하고, 그것이 건너갔을 때만 토스트를 내린다 — 쓰기가 즉시가 아니라, 이전 press가 진행 중일 때 새 복사가 들어오면 토스트가 가리키는 대상이 바뀌기 때문이다. 옛 결과로 내리면 **한 번도 건너간 적 없는 텍스트의 알림을 치우게 된다**. 누른 뒤에도 실패하면 토스트는 그대로 서 있는다 — 건너가지 않은 복사는 사건이 아니라 아직 참인 상태다. ### 서버 저장 preference -- **accent는 세션의 것이고 브라우저는 그것을 칠한다**(`hooks/ui/theme.ts`). 헤더 스와치가 TUI의 - ` p`와 같은 순서로 5색을 순환한다. 브라우저에는 ratatui 팔레트 대응물이 없어 hex를 - 고정하는데, 눈대중이 아니라 기존 amber `#d9a441`(OKLCH L=0.751 C=0.130 h=79.8)의 **명도·채도를 - 유지한 채 hue만 돌려** 파생시킨다 — 어느 프리셋을 골라도 ink 스케일 위에서 가독성이 같다. 적용은 - root의 `--color-accent` 오버라이드 하나로 끝난다. 저장은 `~/.nightcrow/viewer.json` - (`src/session/prefs/`), **저장소별이 아니라 세션 전역**이다: 뷰어는 여러 기기에서 열리고, repo id는 - 프로세스 수명 동안만 안정적이라 저장소별 키는 재시작마다 사라진다. 전달은 3초 `/api/repos` 폴링에 - 얹고 쓰기는 `POST /api/prefs`(cross-site가 트리거할 수 없도록 GET이 아닌 POST, 인증 뒤). 순서 - 문제는 `useViewerPrefs`가 로컬 변경 횟수를 세어 자기보다 오래된 응답의 accent만 버리는 것으로 - 막는다. localStorage는 **첫 페인트 캐시**로만 남는다: CSP가 인라인 스크립트를 막아 - (`script-src 'self'`) 번들 실행 전에는 칠할 수 없는데 폴링 왕복까지 기다리면 매 로드마다 기본 - amber가 번쩍인다. **이 값은 TUI의 것이기도 하다** — 경계와 뒤집은 이유는 - [session.md](session.md#세션-공유-데몬--클라이언트). -- **마지막으로 보던 프로젝트를 서버가 기억한다**(`prefs::active_repo`, `lib/activeRepo.ts`). - **저장은 id가 아니라 worktree path다** — repo id는 프로세스 수명 동안만 안정적이라 재시작 뒤에는 - 아무것도 가리키지 않거나, 더 나쁘게는 *다른* 프로젝트를 가리킨다. 정작 이 기능이 필요한 순간이 - 재시작이다. 클라이언트는 path를 절대 보지 않는다(카탈로그의 불변식): 서버가 POST에서 id→path로, - GET에서 path→id로 옮긴다. **목록과 활성 id는 한 스냅샷에서 뽑는다**(`list_with_active`) — 따로 - 읽으면 목록에 없는 id가 실려 나가고, 그것을 받은 클라이언트는 첫 탭으로 폴백한 뒤 그것을 기록해 - 기억을 영영 덮는다. 살아 있지 않은 id를 보내면 **400**이다. **채택 규칙은 accent·폭과 다르다**: - 활성 프로젝트는 **우선순위 폴백**이라 이미 살아 있는 프로젝트를 보고 있는 페이지는 그대로 둔다 - (`resolveActiveRepo`: 현재 선택 → 기억된 것 → 첫 탭). 폰에서 탭을 바꿨다고 노트북이 읽던 화면에서 - 끌려 나오면 안 된다. -- **닫으면 이웃 탭이 앞으로 온다**(`session::close_repo`의 `successor_of`) — 닫힌 것의 다음, 마지막이었으면 - 이전. 브라우저의 관례이고 TUI의 `workspace::close_at`이 이미 고르던 답이다. **닫을 때 세션이 명시적으로 - 기록한다**: 그러지 않으면 기억된 path가 해석 불가로 남고 각 화면이 자기 폴백으로 떨어져 — 브라우저는 - 자기가 가진 첫 repo, TUI는 세션이 보고한 첫 repo — 넷 중 셋째를 닫으면 다들 첫 탭으로 갔다. TUI의 이웃 - 선택은 그 직후 도착하는 세션 답에 덮여 **죽은 코드였다**. 세션이 답을 내는 지금은 둘이 같은 값을 읽으므로 - 자동으로 일치하고 그 깜빡임도 없다. **앞에 없던 프로젝트를 닫으면 활성은 그대로다** — 포커스는 사람이 - 있는 자리이지 집합이 정할 것이 아니다. 클라이언트도 같은 규칙을 낙관적으로 적용하는데(`lib/successor.ts`) - 폴링이 3초라 그때까지 사람이 보고 있는 화면을 정해야 하기 때문이고, 규칙이 같으므로 도착한 답이 - 아무것도 바꾸지 않는다. **닫는 쪽이 동시에 focus를 옮긴 다른 클라이언트를 덮지는 않는다** - (`set_active_repo_if`: **판단할 때 읽은 값이 아직 그대로일 때만** 쓴다. 닫는 프로젝트를 가리키고 - 있을 때가 아니라 — 아무것도 고르지 않은 세션은 그 값이 비어 있고 활성은 폴백이 정한 것이라, - 닫는 경로와 비교하면 후임을 아예 기록하지 못한다. prefs의 잠긴 read-modify-write 안에서 - 비교하므로 다른 focus 쓰기에 대해 원자적이다). 다만 그것이 보장하는 - 것은 *닫기가* 덮지 않는다는 것이지 그 focus가 살아남는다는 것이 아니다 — 브라우저는 닫은 뒤 - 자기가 착지한 곳을 기록하고(`useRepoPoll`의 단일 지점), 그 쓰기는 닫기의 결정이 아니라 - 클라이언트가 "나는 여기 있다"고 말하는 것이라 다른 전환과 같이 마지막 주장이 이긴다. TUI의 - 닫기는 뒤이어 아무것도 주장하지 않으므로 그쪽에서는 온전히 지켜진다. 그래서 write-generation 가드도 필요 없다. **쓰기는 선택이 정해지는 한 - 곳**(`useRepoPoll`의 effect)에서만 하고 **클라이언트에서 직렬화**한다(`lib/serialWrite.ts`) — - accent·폭은 도착 역전을 감수하지만(다음 폴링이 UI를 서버 값으로 되돌린다) 활성 프로젝트는 폴링이 - UI를 되돌리지 않으므로 **화면과 서버가 조용히 갈라진 채 다음 로드까지 간다**. 직렬화의 대가로 - **`send`는 반드시 끝나야 하므로** 이 쓰기에만 `AbortSignal.timeout`을 건다. **폴이 채택시킨 값은 - 되쓰지 않는다** — 페이지 둘이 열려 있으면 각자가 상대의 쓰기를 따라간 뒤 그 값을 되써서, 활성 - 프로젝트가 초 단위로 진동하며 두 페이지의 터미널 패널을 계속 부쉈다(2026-08-18 관측; 패널은 repo가 - 바뀔 때마다 소켓과 xterm을 새로 만든다). 다만 "이미 서버 값이면 건너뛰기"는 아니다 — 그건 실제로 - 시도했다 되돌렸다(A→B→A를 3초 안에 하면 서버에 B가 남는다). 폴이 채택시킨 **그 값 하나**를 - 기억해두고 직접 전환이 쓰일 때 지우므로, 따라갔던 프로젝트로 손수 돌아오는 것은 여전히 기록된다. - 서버가 아무것도 기억하지 못해 첫 탭으로 떨어진 폴백도 채택이 아니라 이 페이지의 착지라 기록된다. - localStorage 캐시는 쓰지 않는다. -- **사이드바 너비는 divider 드래그로 조절한다**(`hooks/ui/sidebar.ts`). 드래그 원점은 시작에 한 번만 - 재서 중간 re-layout이 원점을 옮기지 못하게 한다. 저장은 accent와 같은 서버 전역이고 첫 페인트 - 캐시로 localStorage도 쓴다. **저장값은 절대 `[280, 720]px`뿐**(서버·`adopt`·load 모두 clamp)이라 - 넓은 화면에서 정한 폭이 좁은 화면에서 잘려 사라지지 않는다. **뷰포트 50% 상한은 표시에만 건다** — - grid track이 `min(px, 50vw)`라 창이 좁아지면 즉시 diff pane이 최소 절반을 지키고 넓히면 저장값까지 - 회복한다. 드래그 중에는 로컬 상태만 갱신하고 놓는 순간 한 번 POST하되, **가로로 유의미하게 - (≥`SIDEBAR_DRAG_THRESHOLD_PX`) 움직였을 때만** 커밋한다(순수 클릭이 `50vw`로 잘린 값을 절대 - 저장값에 덮어쓰지 않도록). **더블클릭은 기본 폭(460)으로 복구**하되 뷰포트 캡이 아니라 절대 - 기본값을 저장한다. 더블클릭은 네이티브 `dblclick`이 아니라 pointer 핸들러 안에서 판정한다 — - 드래그의 `preventDefault`가 합성 click을 삼킬 수 있어서다. divider는 md+ 2컬럼에서만 뜨고 pane - maximize 시엔 숨는다. -- **터미널 패널 높이도 divider 드래그로 조절한다**(`lib/upperPct.ts`, `hooks/ui/upperPct.ts`, - `components/terminal/PanelDivider.tsx`). **재야 하는 구간이 두 grid track에 걸쳐 있고 그 구간에 - 해당하는 element가 없어서** 위쪽 끝은 `
`, 아래쪽 끝은 터미널 `
`에서 각각 잰다(둘 다 - 드래그 시작에 한 번만). 제스처 자체는 사이드바와 **같은 `useDividerDrag`**를 쓰고 축과 측정만 - 다르다. 저장은 `viewer.json`의 `upper_pct`(`[20, 85]` clamp, 기본 55)이고, 퍼센트라 뷰포트 상한이 - 필요 없다. - - **사이드바 폭과 달리 TUI와 공유하지 않는다.** TUI에 대응 값이 있는데도(`config.layout.upper_pct`, - 기본 55) accent처럼 세션 소유로 올리지 않은 이유가 셋이다. (1) 퍼센트는 40행 터미널과 1400px - 창에서 서로 다른 것을 가리키므로 **수렴할 단일 답이 없다**. (2) 이 값이 지배하는 것처럼 보이는 - PTY 크기는 이미 한 클라이언트가 정하므로, 비율을 공유하면 관전자의 **패널만 움직이고 그 안의 - 그리드는 그대로**여서 여백이나 잘림만 늘어난다. (3) "터미널에 화면을 얼마나 줄까"는 보고 있는 - 화면에 대한 질문이라 **fullscreen과 같은 계열**이다 — 이산 버전(maximize)이 클라이언트별인데 - 연속 버전을 공유로 두면 어긋난다. - - **divider는 앱 grid의 다섯 번째 자식이 될 수 없다.** 최상위 grid는 DOM 자식 순서에 걸린 - auto-placement로 매 브레이크포인트에서 보이는 4개를 같은 track에 떨어뜨리므로, element를 하나 더 - 넣으면 나머지가 엉뚱한 track으로 밀린다. 그래서 터미널 패널 **안에서** 그 패널이 이미 그리는 - `border-t` 위에 absolute로 얹는다. maximize 중과 `md` 미만에서는 렌더하지 않는다. -- **패널 최대화는 프로젝트별로 저장한다**(`src/session/prefs/maximized.rs`). "이 프로젝트의 화면을 어떻게 - 배치했나"는 **view state**이고 TUI는 그것을 세션 파일에 프로젝트별로 이미 들고 있었다. - **TUI의 파일에 쓰지 않고 공유하지도 않는다** — `workspace.json`은 TUI가 붙어 있는 동안 TUI 소유이고, - 40행 터미널의 최대화와 1400px 창의 최대화는 애초에 같은 답이 아니다. **키는 절대 경로**로 - (`active_repo`와 같은 이유), 상한은 TUI와 같은 50개. **"아무것도 최대화 안 됨"은 항목의 부재로 - 표현한다** — 그게 압도적으로 흔한 상태라 저장하면 스쳐 지나간 프로젝트마다 "none" 한 줄이 남는다. - 클라이언트 상태는 `useViewerPrefs`에 두는데, 현재 프로젝트를 만들어 내는 `useProjectTabs`보다 - **위에서** 소유해야 하기 때문이다. localStorage 첫 페인트 캐시는 두지 않는다: 키가 repo id라 - 캐시된 맵은 재시작 후 엉뚱한 프로젝트를 가리킨다. -- **프로젝트를 다시 열면 마지막으로 보던 것을 연다**(`src/session/prefs/repo_view.rs`, - `hooks/useRepoViewMemory.ts`). 저장하는 것은 TUI가 세션 파일에 들고 있는 것과 같다 — 탭 - (status/log/tree), 열려 있던 파일(경로 + 커밋 + `diff`/`source` 어느 면), 트리의 펼침. - **트리 커서는 저장하지 않는다** — 브라우저 트리에는 커서가 없고, 돌아갈 행은 파일이 열려 있는 - 행이라 `file`이 이미 그 이름을 대고 있다(TUI의 `tree_selected_path`가 가리키는 것도 같은 행이다). - **파일은 `viewer.json`이지 `workspace.json`이 아니다**: 후자는 TUI가 종료할 - 때 자기 메모리 상태로 통째로 다시 쓰므로(`session::operations::persist_workspace`) 뷰어가 쓴 항목은 - TUI가 한 번 뜨고 지는 것만으로 사라진다. 최대화와 같은 키(절대 경로)·같은 상한(50). - - **경로 검증은 저장하는 자리에서 한다**(`repo_view::sanitize`). 이 값은 다시 "열어라"는 요청이 - 되어 돌아오는데, 들어오는 문은 둘이다 — HTTP와 손으로 고친 `viewer.json`. 두 문에 각각 가드를 - 두면 규칙이 갈라지므로 안쪽 한 곳에서 거른다. HTTP가 답하는 것은 저장소가 표현 못 하는 것뿐이다 - (모르는 repo·탭·면 → 400). - - **기록은 화면을 읽지 않고 "무엇을 요청했나"에서 나온다**(`useRepoViewMemory`의 `note*`, - `useRepoWorkspace`의 opener 래퍼). 화면은 요청의 비동기 그림이라, 탭과 응답 사이에는 직전 것이나 - 빈 것이 떠 있고 둘 다 사람이 고른 것이 아니다. 화면을 읽으면 매 쓰기가 "지금 이 순간이 진짜인가"를 - 먼저 판정해야 하는데, 그 판정은 요청 중복·프로젝트 전환·실패·리렌더로 끝없이 갈라진다(이 기능이 - 리뷰에서 반복해 샌 자리가 정확히 거기다). 행동은 일어나는 순간 자기 뜻을 말하므로, `note`는 그 - 선택 자체를 받고 그 뒤 pane이 어떻게 되든 기록은 흔들리지 않는다. - - **그래서 복원은 아무것도 기록하지 않는다.** 이미 저장된 것을 되돌려 놓는 일이기 때문이다. - 실패한 복원도 마찬가지라, 서버가 한 번 넘어진 것으로 기억이 지워지는 경로가 아예 없다 — 못 연 - 파일은 그대로 기억에 남아 다음에 다시 시도된다. - - **아무도 손대지 않은 화면에만 복원하고, 그 전에 한 선택은 버리지 않고 들고 있는다** - (`pendingRef`). 피커로 연 프로젝트는 응답이 그것을 담기 전에 이미 조작 가능하다 — 그 창에서 - 무언가를 고른 사람은 기다리는 중이 아니라 고르는 중이므로, 그 선택을 모아 두었다가 응답이 - 오면 **저장돼 있던 것 위에 얹어** 기록하고 복원은 하지 않는다. 안 건드린 항목은 옛 답을 - 유지한다. 보관은 방문이 아니라 **프로젝트별**이다 — 답이 오기 전에 자리를 뜨는 건 마음이 - 바뀐 게 아니라서, 돌아오면 그때 반영되고 **화면도 그 값으로 복원된다**(지금 그 화면을 쓰고 - 있는 사람 위에만 복원하지 않는다). - - **`note`는 지금 들고 있는 값에 합친다**(ref로 읽는 `latest`), 이 렌더의 사본이 아니라. 한 tick에 - 선택이 둘일 수 있고(탭 전환은 탭 *그리고* 그 탭이 비우는 pane이다) 렌더 사본에 각각 합치면 - 뒤가 앞을 지운다. poll이 그 사이 옮겨놨을 때 옛 답이 되돌아오는 것도 같은 이유였다. - - **"없다"와 "아직 못 들었다"를 가른다**(`covers`). 응답은 기억이 있는 프로젝트만 싣기 때문에 - 맵에 없는 id는 둘 중 어느 쪽인지 말해주지 않는다. 그래서 **그 프로젝트를 담은 응답을 받은 - 뒤에야** 복원하고, 그 전에는 기록도 하지 않는다 — 피커로 방금 연 프로젝트는 poll보다 먼저 - 화면에 오르고, 그 사이 빈 화면을 적으면 열자마자 기억이 지워진다. 서버가 `last_view` 자체를 - 안 보내는 옛 버전이면 빈 맵으로 읽는다. - - **기억이 없어도 탭은 되돌린다**(`restoreTab(undefined)` = status). 안 그러면 자기 뷰가 없는 - 프로젝트가 **직전 프로젝트의 탭**을 물려받는다. - - **프로젝트가 바뀌면 화면 상태를 렌더 중에 초기화한다**(`useRepoWorkspace`의 `shownRepo`). - effect로 미루면 pane과 탭이 **직전 프로젝트의 것**인 렌더가 한 번 생기고, 그걸 읽는 쪽마다 - 예외를 달아야 한다. React는 이 setState들을 커밋 전에 다시 렌더하므로 그 렌더 자체가 없어진다. - - **화면을 바꾸는 길은 모두 자기 선택을 남긴다** — opener, 탭 전환(그 탭이 비우는 pane까지), - 커밋 파일 목록에서 나오는 `< log`, 트리의 토글과 검색 결과의 디렉터리 열기. 하나라도 빠지면 - 그 행동만 다음 방문에 되돌아오지 않는다. 이미 열려 있는 프로젝트를 다시 고르는 것은 선택이 - 아니므로 아무 일도 하지 않는다(예전에는 pane만 비워, 화면과 기록이 어긋났다). - - **log 탭의 커밋 전체 diff는 파일로 기억하지 않는다.** 여러 파일에 걸쳐 있어 어느 하나가 - 이름을 댈 수 없다 — `openCommit`/`openCommitFiles`는 `noteFile(null)`이다. - - **커밋에서 연 파일은 어느 면이었든 diff로 돌아온다.** 소스 면은 pane의 토글 한 번 거리이고, - 그것만을 위한 opener는 패널에 없다. - - **복원이 여는 pane은 탭이 연 pane과 다르다**(`OpenOptions.restoring`): 폰의 뷰를 파일로 옮기지 - 않고(방금 연 프로젝트는 목록을 보여주는 게 맞다), 실패해도 토스트를 띄우지 않는다 — 아무도 - 지금 요청한 적이 없기 때문이다. 세션 만료(401)만 예외로 그대로 올려보낸다. - - **트리 펼침은 `Sidebar`가 복원한다.** 트리 캐시가 거기 살고 저장소로 keyed되어 있어서다 — - 프로젝트가 바뀌면 통째로 unmount되는 것이 캐시가 섞이지 않는 이유이므로, 복원을 위해 위로 - 끌어올리지 않는다. 복원은 **집합을 그대로 심고**(`seedTreeExpanded`) 경로마다 조상을 펼치지 - 않는다: `withToggled`는 디렉터리를 접어도 그 안쪽 항목을 집합에 남기므로, 조상을 펼치는 방식은 - 사람이 접어둔 디렉터리를 도로 연다. 심는 시점은 **서버가 그 프로젝트를 말해준 뒤, 트리 탭을 - 볼 때**이고, 이미 이 프로젝트에서 무언가를 고른 사람 위에는 심지 않는다. 모양을 알리는 것도 - **디렉터리를 누른 그 순간**이지 캐시를 지켜보다가가 아니다 — 캐시는 복원이 심는 것이기도 해서, - 지켜보면 심은 것이 선택으로 기록되고 심기 전 빈 캐시는 기억을 덮는다. 목록을 못 받은 - 디렉터리는 접지도 알리지도 않는다. - - **클라이언트도 서버와 같은 수에서 자른다**(`MAX_TREE_EXPANDED` 200, 양쪽에 상수). 안 그러면 - 서버가 잘라 돌려주고 클라이언트가 다시 다 보내는 왕복이 poll마다 반복된다. - - **경계**: 프로젝트를 닫는 순간 날아간 기록은 잃는다(닫힌 저장소는 카탈로그에 없어 서버가 400). - 401로 실패한 파일 복원은 재로그인 후 자동 재시도되지 않는다 — 다시 열거나 새로고침하면 된다. - 트리는 로그인 화면이 사이드바를 unmount하므로 돌아오면 다시 심는다. 탭 복원이 effect라 한 박자 - 늦어, 트리 탭에 있다가 status가 기억된 프로젝트로 옮기면 그 프로젝트의 트리를 한 번 심는다. -- **폰 터치 타겟을 넓힌다**: 목록 행·사이드바 탭·pane 버튼·`ProjectMenu` 항목은 `md` 미만에서 세로 - 패딩과 히트 영역을 키우고 `md:`로 기존 밀도를 복원한다. hover가 안 먹는 터치를 위해 `active:` 상태를 - 병행한다. +- **accent는 세션의 것이고 브라우저는 그것을 칠한다**(`hooks/ui/theme.ts`). 헤더 스와치가 TUI의 ` p`와 같은 순서로 5색을 순환한다. 브라우저에는 ratatui 팔레트 대응물이 없어 hex를 고정하는데, 눈대중이 아니라 기존 amber `#d9a441`(OKLCH L=0.751 C=0.130 h=79.8)의 **명도·채도를 유지한 채 hue만 돌려** 파생시킨다 — 어느 프리셋을 골라도 ink 스케일 위에서 가독성이 같다. 적용은 root의 `--color-accent` 오버라이드 하나로 끝난다. 저장은 `~/.nightcrow/viewer.json` (`src/session/prefs/`), **저장소별이 아니라 세션 전역**이다: 뷰어는 여러 기기에서 열리고, repo id는 프로세스 수명 동안만 안정적이라 저장소별 키는 재시작마다 사라진다. 전달은 3초 `/api/repos` 폴링에 얹고 쓰기는 `POST /api/prefs`(cross-site가 트리거할 수 없도록 GET이 아닌 POST, 인증 뒤). 순서 문제는 `useViewerPrefs`가 로컬 변경 횟수를 세어 자기보다 오래된 응답의 accent만 버리는 것으로 막는다. localStorage는 **첫 페인트 캐시**로만 남는다: CSP가 인라인 스크립트를 막아 (`script-src 'self'`) 번들 실행 전에는 칠할 수 없는데 폴링 왕복까지 기다리면 매 로드마다 기본 amber가 번쩍인다. **이 값은 TUI의 것이기도 하다** — 경계와 뒤집은 이유는 [session.md](session.md#세션-공유-데몬--클라이언트). +- **마지막으로 보던 프로젝트를 서버가 기억한다**(`prefs::active_repo`, `lib/activeRepo.ts`). **저장은 id가 아니라 worktree path다** — repo id는 프로세스 수명 동안만 안정적이라 재시작 뒤에는 아무것도 가리키지 않거나, 더 나쁘게는 *다른* 프로젝트를 가리킨다. 정작 이 기능이 필요한 순간이 재시작이다. 클라이언트는 path를 절대 보지 않는다(카탈로그의 불변식): 서버가 POST에서 id→path로, GET에서 path→id로 옮긴다. **목록과 활성 id는 한 스냅샷에서 뽑는다**(`list_with_active`) — 따로 읽으면 목록에 없는 id가 실려 나가고, 그것을 받은 클라이언트는 첫 탭으로 폴백한 뒤 그것을 기록해 기억을 영영 덮는다. 살아 있지 않은 id를 보내면 **400**이다. **채택 규칙은 accent·폭과 다르다**: 활성 프로젝트는 **우선순위 폴백**이라 이미 살아 있는 프로젝트를 보고 있는 페이지는 그대로 둔다 (`resolveActiveRepo`: 현재 선택 → 기억된 것 → 첫 탭). 폰에서 탭을 바꿨다고 노트북이 읽던 화면에서 끌려 나오면 안 된다. +- **닫으면 이웃 탭이 앞으로 온다**(`session::close_repo`의 `successor_of`) — 닫힌 것의 다음, 마지막이었으면 이전. 브라우저의 관례이고 TUI의 `workspace::close_at`이 이미 고르던 답이다. **닫을 때 세션이 명시적으로 기록한다**: 그러지 않으면 기억된 path가 해석 불가로 남고 각 화면이 자기 폴백으로 떨어져 — 브라우저는 자기가 가진 첫 repo, TUI는 세션이 보고한 첫 repo — 넷 중 셋째를 닫으면 다들 첫 탭으로 갔다. TUI의 이웃 선택은 그 직후 도착하는 세션 답에 덮여 **죽은 코드였다**. 세션이 답을 내는 지금은 둘이 같은 값을 읽으므로 자동으로 일치하고 그 깜빡임도 없다. **앞에 없던 프로젝트를 닫으면 활성은 그대로다** — 포커스는 사람이 있는 자리이지 집합이 정할 것이 아니다. 클라이언트도 같은 규칙을 낙관적으로 적용하는데(`lib/successor.ts`) 폴링이 3초라 그때까지 사람이 보고 있는 화면을 정해야 하기 때문이고, 규칙이 같으므로 도착한 답이 아무것도 바꾸지 않는다. **닫는 쪽이 동시에 focus를 옮긴 다른 클라이언트를 덮지는 않는다** (`set_active_repo_if`: **판단할 때 읽은 값이 아직 그대로일 때만** 쓴다. 닫는 프로젝트를 가리키고 있을 때가 아니라 — 아무것도 고르지 않은 세션은 그 값이 비어 있고 활성은 폴백이 정한 것이라, 닫는 경로와 비교하면 후임을 아예 기록하지 못한다. prefs의 잠긴 read-modify-write 안에서 비교하므로 다른 focus 쓰기에 대해 원자적이다). 다만 그것이 보장하는 것은 *닫기가* 덮지 않는다는 것이지 그 focus가 살아남는다는 것이 아니다 — 브라우저는 닫은 뒤 자기가 착지한 곳을 기록하고(`useRepoPoll`의 단일 지점), 그 쓰기는 닫기의 결정이 아니라 클라이언트가 "나는 여기 있다"고 말하는 것이라 다른 전환과 같이 마지막 주장이 이긴다. TUI의 닫기는 뒤이어 아무것도 주장하지 않으므로 그쪽에서는 온전히 지켜진다. 그래서 write-generation 가드도 필요 없다. **쓰기는 선택이 정해지는 한 곳**(`useRepoPoll`의 effect)에서만 하고 **클라이언트에서 직렬화**한다(`lib/serialWrite.ts`) — accent·폭은 도착 역전을 감수하지만(다음 폴링이 UI를 서버 값으로 되돌린다) 활성 프로젝트는 폴링이 UI를 되돌리지 않으므로 **화면과 서버가 조용히 갈라진 채 다음 로드까지 간다**. 직렬화의 대가로 **`send`는 반드시 끝나야 하므로** 이 쓰기에만 `AbortSignal.timeout`을 건다. **폴이 채택시킨 값은 되쓰지 않는다** — 페이지 둘이 열려 있으면 각자가 상대의 쓰기를 따라간 뒤 그 값을 되써서, 활성 프로젝트가 초 단위로 진동하며 두 페이지의 터미널 패널을 계속 부쉈다(2026-08-18 관측; 패널은 repo가 바뀔 때마다 소켓과 xterm을 새로 만든다). 다만 "이미 서버 값이면 건너뛰기"는 아니다 — 그건 실제로 시도했다 되돌렸다(A→B→A를 3초 안에 하면 서버에 B가 남는다). 폴이 채택시킨 **그 값 하나**를 기억해두고 직접 전환이 쓰일 때 지우므로, 따라갔던 프로젝트로 손수 돌아오는 것은 여전히 기록된다. 서버가 아무것도 기억하지 못해 첫 탭으로 떨어진 폴백도 채택이 아니라 이 페이지의 착지라 기록된다. localStorage 캐시는 쓰지 않는다. +- **사이드바 너비는 divider 드래그로 조절한다**(`hooks/ui/sidebar.ts`). 드래그 원점은 시작에 한 번만 재서 중간 re-layout이 원점을 옮기지 못하게 한다. 저장은 accent와 같은 서버 전역이고 첫 페인트 캐시로 localStorage도 쓴다. **저장값은 절대 `[280, 720]px`뿐**(서버·`adopt`·load 모두 clamp)이라 넓은 화면에서 정한 폭이 좁은 화면에서 잘려 사라지지 않는다. **뷰포트 50% 상한은 표시에만 건다** — grid track이 `min(px, 50vw)`라 창이 좁아지면 즉시 diff pane이 최소 절반을 지키고 넓히면 저장값까지 회복한다. 드래그 중에는 로컬 상태만 갱신하고 놓는 순간 한 번 POST하되, **가로로 유의미하게 (≥`SIDEBAR_DRAG_THRESHOLD_PX`) 움직였을 때만** 커밋한다(순수 클릭이 `50vw`로 잘린 값을 절대 저장값에 덮어쓰지 않도록). **더블클릭은 기본 폭(460)으로 복구**하되 뷰포트 캡이 아니라 절대 기본값을 저장한다. 더블클릭은 네이티브 `dblclick`이 아니라 pointer 핸들러 안에서 판정한다 — 드래그의 `preventDefault`가 합성 click을 삼킬 수 있어서다. divider는 md+ 2컬럼에서만 뜨고 pane maximize 시엔 숨는다. +- **터미널 패널 높이도 divider 드래그로 조절한다**(`lib/upperPct.ts`, `hooks/ui/upperPct.ts`, `components/terminal/PanelDivider.tsx`). **재야 하는 구간이 두 grid track에 걸쳐 있고 그 구간에 해당하는 element가 없어서** 위쪽 끝은 `
`, 아래쪽 끝은 터미널 `
`에서 각각 잰다(둘 다 드래그 시작에 한 번만). 제스처 자체는 사이드바와 **같은 `useDividerDrag`**를 쓰고 축과 측정만 다르다. 저장은 `viewer.json`의 `upper_pct`(`[20, 85]` clamp, 기본 55)이고, 퍼센트라 뷰포트 상한이 필요 없다. + - **사이드바 폭과 달리 TUI와 공유하지 않는다.** TUI에 대응 값이 있는데도(`config.layout.upper_pct`, 기본 55) accent처럼 세션 소유로 올리지 않은 이유가 셋이다. (1) 퍼센트는 40행 터미널과 1400px 창에서 서로 다른 것을 가리키므로 **수렴할 단일 답이 없다**. (2) 이 값이 지배하는 것처럼 보이는 PTY 크기는 이미 한 클라이언트가 정하므로, 비율을 공유하면 관전자의 **패널만 움직이고 그 안의 그리드는 그대로**여서 여백이나 잘림만 늘어난다. (3) "터미널에 화면을 얼마나 줄까"는 보고 있는 화면에 대한 질문이라 **fullscreen과 같은 계열**이다 — 이산 버전(maximize)이 클라이언트별인데 연속 버전을 공유로 두면 어긋난다. + - **divider는 앱 grid의 다섯 번째 자식이 될 수 없다.** 최상위 grid는 DOM 자식 순서에 걸린 auto-placement로 매 브레이크포인트에서 보이는 4개를 같은 track에 떨어뜨리므로, element를 하나 더 넣으면 나머지가 엉뚱한 track으로 밀린다. 그래서 터미널 패널 **안에서** 그 패널이 이미 그리는 `border-t` 위에 absolute로 얹는다. maximize 중과 `md` 미만에서는 렌더하지 않는다. +- **패널 최대화는 프로젝트별로 저장한다**(`src/session/prefs/maximized.rs`). "이 프로젝트의 화면을 어떻게 배치했나"는 **view state**이고 TUI는 그것을 세션 파일에 프로젝트별로 이미 들고 있었다. **TUI의 파일에 쓰지 않고 공유하지도 않는다** — `workspace.json`은 TUI가 붙어 있는 동안 TUI 소유이고, 40행 터미널의 최대화와 1400px 창의 최대화는 애초에 같은 답이 아니다. **키는 절대 경로**로 (`active_repo`와 같은 이유), 상한은 TUI와 같은 50개. **"아무것도 최대화 안 됨"은 항목의 부재로 표현한다** — 그게 압도적으로 흔한 상태라 저장하면 스쳐 지나간 프로젝트마다 "none" 한 줄이 남는다. 클라이언트 상태는 `useViewerPrefs`에 두는데, 현재 프로젝트를 만들어 내는 `useProjectTabs`보다 **위에서** 소유해야 하기 때문이다. localStorage 첫 페인트 캐시는 두지 않는다: 키가 repo id라 캐시된 맵은 재시작 후 엉뚱한 프로젝트를 가리킨다. +- **프로젝트를 다시 열면 마지막으로 보던 것을 연다**(`src/session/prefs/repo_view.rs`, `hooks/useRepoViewMemory.ts`). 저장하는 것은 TUI가 세션 파일에 들고 있는 것과 같다 — 탭 (status/log/tree), 열려 있던 파일(경로 + 커밋 + `diff`/`source` 어느 면), 트리의 펼침. **트리 커서는 저장하지 않는다** — 브라우저 트리에는 커서가 없고, 돌아갈 행은 파일이 열려 있는 행이라 `file`이 이미 그 이름을 대고 있다(TUI의 `tree_selected_path`가 가리키는 것도 같은 행이다). **파일은 `viewer.json`이지 `workspace.json`이 아니다**: 후자는 TUI가 종료할 때 자기 메모리 상태로 통째로 다시 쓰므로(`session::operations::persist_workspace`) 뷰어가 쓴 항목은 TUI가 한 번 뜨고 지는 것만으로 사라진다. 최대화와 같은 키(절대 경로)·같은 상한(50). + - **경로 검증은 저장하는 자리에서 한다**(`repo_view::sanitize`). 이 값은 다시 "열어라"는 요청이 되어 돌아오는데, 들어오는 문은 둘이다 — HTTP와 손으로 고친 `viewer.json`. 두 문에 각각 가드를 두면 규칙이 갈라지므로 안쪽 한 곳에서 거른다. HTTP가 답하는 것은 저장소가 표현 못 하는 것뿐이다 (모르는 repo·탭·면 → 400). + - **기록은 화면을 읽지 않고 "무엇을 요청했나"에서 나온다**(`useRepoViewMemory`의 `note*`, `useRepoWorkspace`의 opener 래퍼). 화면은 요청의 비동기 그림이라, 탭과 응답 사이에는 직전 것이나 빈 것이 떠 있고 둘 다 사람이 고른 것이 아니다. 화면을 읽으면 매 쓰기가 "지금 이 순간이 진짜인가"를 먼저 판정해야 하는데, 그 판정은 요청 중복·프로젝트 전환·실패·리렌더로 끝없이 갈라진다(이 기능이 리뷰에서 반복해 샌 자리가 정확히 거기다). 행동은 일어나는 순간 자기 뜻을 말하므로, `note`는 그 선택 자체를 받고 그 뒤 pane이 어떻게 되든 기록은 흔들리지 않는다. + - **그래서 복원은 아무것도 기록하지 않는다.** 이미 저장된 것을 되돌려 놓는 일이기 때문이다. 실패한 복원도 마찬가지라, 서버가 한 번 넘어진 것으로 기억이 지워지는 경로가 아예 없다 — 못 연 파일은 그대로 기억에 남아 다음에 다시 시도된다. + - **아무도 손대지 않은 화면에만 복원하고, 그 전에 한 선택은 버리지 않고 들고 있는다** (`pendingRef`). 피커로 연 프로젝트는 응답이 그것을 담기 전에 이미 조작 가능하다 — 그 창에서 무언가를 고른 사람은 기다리는 중이 아니라 고르는 중이므로, 그 선택을 모아 두었다가 응답이 오면 **저장돼 있던 것 위에 얹어** 기록하고 복원은 하지 않는다. 안 건드린 항목은 옛 답을 유지한다. 보관은 방문이 아니라 **프로젝트별**이다 — 답이 오기 전에 자리를 뜨는 건 마음이 바뀐 게 아니라서, 돌아오면 그때 반영되고 **화면도 그 값으로 복원된다**(지금 그 화면을 쓰고 있는 사람 위에만 복원하지 않는다). + - **`note`는 지금 들고 있는 값에 합친다**(ref로 읽는 `latest`), 이 렌더의 사본이 아니라. 한 tick에 선택이 둘일 수 있고(탭 전환은 탭 *그리고* 그 탭이 비우는 pane이다) 렌더 사본에 각각 합치면 뒤가 앞을 지운다. poll이 그 사이 옮겨놨을 때 옛 답이 되돌아오는 것도 같은 이유였다. + - **"없다"와 "아직 못 들었다"를 가른다**(`covers`). 응답은 기억이 있는 프로젝트만 싣기 때문에 맵에 없는 id는 둘 중 어느 쪽인지 말해주지 않는다. 그래서 **그 프로젝트를 담은 응답을 받은 뒤에야** 복원하고, 그 전에는 기록도 하지 않는다 — 피커로 방금 연 프로젝트는 poll보다 먼저 화면에 오르고, 그 사이 빈 화면을 적으면 열자마자 기억이 지워진다. 서버가 `last_view` 자체를 안 보내는 옛 버전이면 빈 맵으로 읽는다. + - **기억이 없어도 탭은 되돌린다**(`restoreTab(undefined)` = status). 안 그러면 자기 뷰가 없는 프로젝트가 **직전 프로젝트의 탭**을 물려받는다. + - **프로젝트가 바뀌면 화면 상태를 렌더 중에 초기화한다**(`useRepoWorkspace`의 `shownRepo`). effect로 미루면 pane과 탭이 **직전 프로젝트의 것**인 렌더가 한 번 생기고, 그걸 읽는 쪽마다 예외를 달아야 한다. React는 이 setState들을 커밋 전에 다시 렌더하므로 그 렌더 자체가 없어진다. + - **화면을 바꾸는 길은 모두 자기 선택을 남긴다** — opener, 탭 전환(그 탭이 비우는 pane까지), 커밋 파일 목록에서 나오는 `< log`, 트리의 토글과 검색 결과의 디렉터리 열기. 하나라도 빠지면 그 행동만 다음 방문에 되돌아오지 않는다. 이미 열려 있는 프로젝트를 다시 고르는 것은 선택이 아니므로 아무 일도 하지 않는다(예전에는 pane만 비워, 화면과 기록이 어긋났다). + - **log 탭의 커밋 전체 diff는 파일로 기억하지 않는다.** 여러 파일에 걸쳐 있어 어느 하나가 이름을 댈 수 없다 — `openCommit`/`openCommitFiles`는 `noteFile(null)`이다. + - **커밋에서 연 파일은 어느 면이었든 diff로 돌아온다.** 소스 면은 pane의 토글 한 번 거리이고, 그것만을 위한 opener는 패널에 없다. + - **복원이 여는 pane은 탭이 연 pane과 다르다**(`OpenOptions.restoring`): 폰의 뷰를 파일로 옮기지 않고(방금 연 프로젝트는 목록을 보여주는 게 맞다), 실패해도 토스트를 띄우지 않는다 — 아무도 지금 요청한 적이 없기 때문이다. 세션 만료(401)만 예외로 그대로 올려보낸다. + - **트리 펼침은 `Sidebar`가 복원한다.** 트리 캐시가 거기 살고 저장소로 keyed되어 있어서다 — 프로젝트가 바뀌면 통째로 unmount되는 것이 캐시가 섞이지 않는 이유이므로, 복원을 위해 위로 끌어올리지 않는다. 복원은 **집합을 그대로 심고**(`seedTreeExpanded`) 경로마다 조상을 펼치지 않는다: `withToggled`는 디렉터리를 접어도 그 안쪽 항목을 집합에 남기므로, 조상을 펼치는 방식은 사람이 접어둔 디렉터리를 도로 연다. 심는 시점은 **서버가 그 프로젝트를 말해준 뒤, 트리 탭을 볼 때**이고, 이미 이 프로젝트에서 무언가를 고른 사람 위에는 심지 않는다. 모양을 알리는 것도 **디렉터리를 누른 그 순간**이지 캐시를 지켜보다가가 아니다 — 캐시는 복원이 심는 것이기도 해서, 지켜보면 심은 것이 선택으로 기록되고 심기 전 빈 캐시는 기억을 덮는다. 목록을 못 받은 디렉터리는 접지도 알리지도 않는다. + - **클라이언트도 서버와 같은 수에서 자른다**(`MAX_TREE_EXPANDED` 200, 양쪽에 상수). 안 그러면 서버가 잘라 돌려주고 클라이언트가 다시 다 보내는 왕복이 poll마다 반복된다. + - **경계**: 프로젝트를 닫는 순간 날아간 기록은 잃는다(닫힌 저장소는 카탈로그에 없어 서버가 400). 401로 실패한 파일 복원은 재로그인 후 자동 재시도되지 않는다 — 다시 열거나 새로고침하면 된다. 트리는 로그인 화면이 사이드바를 unmount하므로 돌아오면 다시 심는다. 탭 복원이 effect라 한 박자 늦어, 트리 탭에 있다가 status가 기억된 프로젝트로 옮기면 그 프로젝트의 트리를 한 번 심는다. +- **폰 터치 타겟을 넓힌다**: 목록 행·사이드바 탭·pane 버튼·`ProjectMenu` 항목은 `md` 미만에서 세로 패딩과 히트 영역을 키우고 `md:`로 기존 밀도를 복원한다. hover가 안 먹는 터치를 위해 `active:` 상태를 병행한다. ## 클론 (`src/git/clone.rs`, `web/viewer/clone_jobs/`, `server/clone_routes.rs`) 폴더 피커가 보고 있는 디렉토리에 원격을 클론하고, 끝나면 그 경로를 repo로 연다. -- **URL로 클론하는 것은 `git` 바이너리에 위임한다. libgit2를 쓰지 않는다** — 벤더링된 빌드에 SSH - 전송이 없어(`libgit2-sys`가 `libssh2-sys`를 끌어오지 않음) 가장 흔한 `git@host:path`가 아예 해석되지 - 않고, credential helper·`insteadOf`·에이전트가 쥔 키도 libgit2는 모른다. 이것은 프로젝트가 피하는 - "git 출력 파싱"이 아니다 — stdout을 읽지 않고 종료 상태와 실패 시 stderr만 본다. 대가로 런타임에 - `git`이 PATH에 있어야 하는데, 그 여부를 시작 시 한 번 재서 `/api/repos`의 `can_clone`으로 실어 보내 - 클라이언트가 반드시 실패할 job을 시작하는 대신 폼을 비활성화한다(서버 실행 중 git을 설치하면 재시작 - 전까지 반영되지 않는다). 서버 쪽 검사는 그대로 남아 버튼은 UX일 뿐 유일한 방어가 아니다. -- **URL 스킴 화이트리스트는 보안 경계다**(`validate_clone_url`). git은 `ext::`를 **그 명령을 - 실행해서** 해석하므로 검증하지 않은 URL은 서버에서의 원격 코드 실행이다. **URL을 `--` 뒤 argv - 항목으로 넘기는 것으로는 막히지 않는다** — 스킴은 인자 파싱이 끝난 뒤에 해석된다. 그래서 - `https`/`http`/`ssh`/`git+ssh`와 scp 형식(`user@host:path`)만 통과시키고 `file://`와 로컬 경로도 - 뺀다(로컬 디렉토리는 피커로 이미 닿는다). **`git://`도 뺐다** — 인증도 암호화도 없어 경로 위의 - 누구든 임의 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 - 슬롯을 재시작까지 쥔다. -- 대상 디렉토리 이름은 **클라이언트가 주지 않고 URL에서 파생**하며 `mkdir`과 같은 규칙(단일 평범 - 세그먼트, 숨김 아님)을 통과해야 한다. 부모를 먼저 canonicalize하고 **목적지는 `exists()`로 검사하는 - 대신 `create_dir`로 선점한다** — 검사와 사용 사이에 심볼릭 링크가 끼어들 수 있고 git은 그걸 따라가 - 부모 밖에 쓴다. `create_dir`는 원자적이고 마지막 경로 요소의 링크를 따라가지 않는다. 실패한 클론은 - 그 디렉토리를 **재귀가 아니라 `remove_dir`로** 지운다 — 그 사이 다른 무언가가 그 경로를 차지했더라도 - 내용을 파괴할 수 없게. **비어 있지 않으면 남는다**: checkout 단계에서 실패하면 git은 저장소를 - 의도적으로 보존한다(`JUNK_LEAVE_REPO`). 남은 디렉토리가 같은 이름의 재시도를 막지만 그건 눈에 보이는 - 불편이고 남의 파일을 지우는 것은 아니다. -- **남는 한계 (수용)**: `create_dir` 성공 이후 `git`이 그 경로를 여는 사이에, 부모에 쓸 수 있는 로컬 - 프로세스가 목적지를 심볼릭 링크로 바꿔치면 git이 부모 밖에 쓸 수 있다. 경로가 아니라 열린 핸들로 - 작업해야 닫히는 창인데 `git`은 별도 프로세스라 경로로만 받는다. 같은 UID라면 새로운 권한이 아니지만 - **부모가 공유 디렉토리(`/tmp` 등)면 다른 UID도 해당된다** — 즉 "이미 할 수 있는 일"이라는 논리는 같은 - UID에만 성립한다. 공유 디렉토리를 부모로 고르지 않는 것으로 피한다. -- **클론은 자기를 시작한 요청보다 오래 산다**(`clone_jobs/`). `POST /api/clone`은 스레드를 띄우고 job - id로 답한 뒤 클라이언트가 `GET /api/clone?job=`로 폴링한다. 연결이 끊겨도 클론은 취소되지 않는다 — - 터미널 hub가 PTY를 유지하는 것과 같은 선택이다. **동시 클론은 하나로 제한**하고 판정과 등록을 **같은 락 - 안에서** 한다(`try_start`) — 밖에서 묻고 나중에 넣으면 병렬 요청이 저마다 빈 레지스트리를 보는 - check-then-act 경합이 된다. 끝난 job은 다음 시작 때 정리하되 **running인 job은 절대 evict하지 - 않는다**. 정리된 job을 뒤늦게 폴링하면 404가 나는데 클라이언트는 이를 **네트워크 오류와 구분해 종료로 - 취급**한다(재시도하면 폼이 "Cloning…"에 영원히 걸린다). -- 인증이 필요한 원격에서 멈추지 않도록 `GIT_TERMINAL_PROMPT=0`으로 돌린다. 죽은 연결은 - `http.lowSpeedLimit=1024`/`lowSpeedTime=60`이 끊는다 — 정책 문턱이라 정당하지만 극단적으로 느린 전송도 - 함께 끊긴다. **벽시계 타임아웃을 두지 않은 이유**는 그것이 "느린 것"과 "멈춘 것"을 전혀 구분하지 못하기 - 때문이다 — 큰 저장소는 정당하게 몇 십 분이 걸린다. ssh에는 `GIT_SSH_COMMAND`로 - `ConnectTimeout`/`ServerAliveInterval`/`CountMax`를 건다. **남는 한계**: 이것들은 *멈춘* 것을 끊을 뿐 - 완료를 보장하는 상한이 아니다. 실패 메시지는 redact하지 않고 git의 마지막 줄을 그대로 보낸다 — - "repository not found"는 사용자가 친 URL에 대한 원격의 말이지 서버 내부 정보가 아니다. -- **진행 중인 클론은 job id 없이도 찾을 수 있다**(`CloneJobs::running`). job id를 아는 것은 클론을 - 시작한 그 페이지뿐인데 그 페이지는 리로드되거나 닫힐 수 있다. `job` 없는 조회는 지금 running인 job의 - id로(없으면 `null`로) 답한다 — 동시 클론이 하나이므로 모호하지 않다. `null`은 에러가 아니라 "붙을 - 것이 없다"는 명시적 답이다. 숫자로 파싱되지 않는 `job`은 여전히 400 — 오타가 조용히 "무엇이 돌고 - 있나"로 바뀌면 그 클라이언트는 남의 job에 붙는다. -- **클론 job의 주인은 폴더 피커가 아니라 그 위다**(`useClone`을 `useAppViewModel`에서 호출). 훅을 피커 안에서 - 부르면 다이얼로그를 닫는 순간 관측자가 unmount되어 완료 토스트도 실패 메시지도 없고 끝난 repo도 열리지 - 않는다. 피커는 `(부모 경로, URL)`을 올리기만 한다. **로그인 직후 진행 중인 job에 자동으로 붙는다** — - 붙을 게 없거나 probe가 실패하면 조용히 넘어간다. +- **URL로 클론하는 것은 `git` 바이너리에 위임한다. libgit2를 쓰지 않는다** — 벤더링된 빌드에 SSH 전송이 없어(`libgit2-sys`가 `libssh2-sys`를 끌어오지 않음) 가장 흔한 `git@host:path`가 아예 해석되지 않고, credential helper·`insteadOf`·에이전트가 쥔 키도 libgit2는 모른다. 이것은 프로젝트가 피하는 "git 출력 파싱"이 아니다 — stdout을 읽지 않고 종료 상태와 실패 시 stderr만 본다. 대가로 런타임에 `git`이 PATH에 있어야 하는데, 그 여부를 시작 시 한 번 재서 `/api/repos`의 `can_clone`으로 실어 보내 클라이언트가 반드시 실패할 job을 시작하는 대신 폼을 비활성화한다(서버 실행 중 git을 설치하면 재시작 전까지 반영되지 않는다). 서버 쪽 검사는 그대로 남아 버튼은 UX일 뿐 유일한 방어가 아니다. +- **URL 스킴 화이트리스트는 보안 경계다**(`validate_clone_url`). git은 `ext::`를 **그 명령을 실행해서** 해석하므로 검증하지 않은 URL은 서버에서의 원격 코드 실행이다. **URL을 `--` 뒤 argv 항목으로 넘기는 것으로는 막히지 않는다** — 스킴은 인자 파싱이 끝난 뒤에 해석된다. 그래서 `https`/`http`/`ssh`/`git+ssh`와 scp 형식(`user@host:path`)만 통과시키고 `file://`와 로컬 경로도 뺀다(로컬 디렉토리는 피커로 이미 닿는다). **`git://`도 뺐다** — 인증도 암호화도 없어 경로 위의 누구든 임의 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 슬롯을 재시작까지 쥔다. +- 대상 디렉토리 이름은 **클라이언트가 주지 않고 URL에서 파생**하며 `mkdir`과 같은 규칙(단일 평범 세그먼트, 숨김 아님)을 통과해야 한다. 부모를 먼저 canonicalize하고 **목적지는 `exists()`로 검사하는 대신 `create_dir`로 선점한다** — 검사와 사용 사이에 심볼릭 링크가 끼어들 수 있고 git은 그걸 따라가 부모 밖에 쓴다. `create_dir`는 원자적이고 마지막 경로 요소의 링크를 따라가지 않는다. 실패한 클론은 그 디렉토리를 **재귀가 아니라 `remove_dir`로** 지운다 — 그 사이 다른 무언가가 그 경로를 차지했더라도 내용을 파괴할 수 없게. **비어 있지 않으면 남는다**: checkout 단계에서 실패하면 git은 저장소를 의도적으로 보존한다(`JUNK_LEAVE_REPO`). 남은 디렉토리가 같은 이름의 재시도를 막지만 그건 눈에 보이는 불편이고 남의 파일을 지우는 것은 아니다. +- **남는 한계 (수용)**: `create_dir` 성공 이후 `git`이 그 경로를 여는 사이에, 부모에 쓸 수 있는 로컬 프로세스가 목적지를 심볼릭 링크로 바꿔치면 git이 부모 밖에 쓸 수 있다. 경로가 아니라 열린 핸들로 작업해야 닫히는 창인데 `git`은 별도 프로세스라 경로로만 받는다. 같은 UID라면 새로운 권한이 아니지만 **부모가 공유 디렉토리(`/tmp` 등)면 다른 UID도 해당된다** — 즉 "이미 할 수 있는 일"이라는 논리는 같은 UID에만 성립한다. 공유 디렉토리를 부모로 고르지 않는 것으로 피한다. +- **클론은 자기를 시작한 요청보다 오래 산다**(`clone_jobs/`). `POST /api/clone`은 스레드를 띄우고 job id로 답한 뒤 클라이언트가 `GET /api/clone?job=`로 폴링한다. 연결이 끊겨도 클론은 취소되지 않는다 — 터미널 hub가 PTY를 유지하는 것과 같은 선택이다. **동시 클론은 하나로 제한**하고 판정과 등록을 **같은 락 안에서** 한다(`try_start`) — 밖에서 묻고 나중에 넣으면 병렬 요청이 저마다 빈 레지스트리를 보는 check-then-act 경합이 된다. 끝난 job은 다음 시작 때 정리하되 **running인 job은 절대 evict하지 않는다**. 정리된 job을 뒤늦게 폴링하면 404가 나는데 클라이언트는 이를 **네트워크 오류와 구분해 종료로 취급**한다(재시도하면 폼이 "Cloning…"에 영원히 걸린다). +- 인증이 필요한 원격에서 멈추지 않도록 `GIT_TERMINAL_PROMPT=0`으로 돌린다. 죽은 연결은 `http.lowSpeedLimit=1024`/`lowSpeedTime=60`이 끊는다 — 정책 문턱이라 정당하지만 극단적으로 느린 전송도 함께 끊긴다. **벽시계 타임아웃을 두지 않은 이유**는 그것이 "느린 것"과 "멈춘 것"을 전혀 구분하지 못하기 때문이다 — 큰 저장소는 정당하게 몇 십 분이 걸린다. ssh에는 `GIT_SSH_COMMAND`로 `ConnectTimeout`/`ServerAliveInterval`/`CountMax`를 건다. **남는 한계**: 이것들은 *멈춘* 것을 끊을 뿐 완료를 보장하는 상한이 아니다. 실패 메시지는 redact하지 않고 git의 마지막 줄을 그대로 보낸다 — "repository not found"는 사용자가 친 URL에 대한 원격의 말이지 서버 내부 정보가 아니다. +- **진행 중인 클론은 job id 없이도 찾을 수 있다**(`CloneJobs::running`). job id를 아는 것은 클론을 시작한 그 페이지뿐인데 그 페이지는 리로드되거나 닫힐 수 있다. `job` 없는 조회는 지금 running인 job의 id로(없으면 `null`로) 답한다 — 동시 클론이 하나이므로 모호하지 않다. `null`은 에러가 아니라 "붙을 것이 없다"는 명시적 답이다. 숫자로 파싱되지 않는 `job`은 여전히 400 — 오타가 조용히 "무엇이 돌고 있나"로 바뀌면 그 클라이언트는 남의 job에 붙는다. +- **클론 job의 주인은 폴더 피커가 아니라 그 위다**(`useClone`을 `useAppViewModel`에서 호출). 훅을 피커 안에서 부르면 다이얼로그를 닫는 순간 관측자가 unmount되어 완료 토스트도 실패 메시지도 없고 끝난 repo도 열리지 않는다. 피커는 `(부모 경로, URL)`을 올리기만 한다. **로그인 직후 진행 중인 job에 자동으로 붙는다** — 붙을 게 없거나 probe가 실패하면 조용히 넘어간다. ## 알려진 잔여 위험 (수용 또는 후속) -- **저장소 루트가 넓어질 수 있다.** 핸들러는 `Repository::discover`로 저장소를 열고 `repo.workdir()` - 기준으로 경로를 푼다. `discover`는 상위로 올라가므로, 저장소가 아닌 디렉토리를 서빙하면 - (`serve --repo ~/notes`, `$HOME`이 저장소일 때) 브라우징 루트가 `$HOME`으로 넓어진다. traversal은 - 여전히 불가능하지만 운영자가 지정한 범위보다 넓다. 후속으로 `entry.path`에서 workdir을 파생시켜야 한다. -- **로그인 rate limiter가 프로세스 전역**이라 미인증 요청 3회/분으로 정당한 사용자의 로그인을 잠글 수 - 있다. 단일 비밀번호 모델의 대가. -- **터미널은 클라이언트 간 격리가 없다.** 연결된 어느 클라이언트든 그 저장소의 아무 pane에 - 입력·리사이즈·종료할 수 있다. 단일 공유 비밀번호에서는 일관되지만 pane 소유권 개념이 없다는 뜻이다. -- **PTY는 연결이 끊겨도 회수되지 않는다**(재접속 시 세션 유지 목적). 저장소당 최대 8개가 프로세스 수명 - 동안 남는다. -- **세션 토큰은 디스크에 영속화된다.** `~/.nightcrow/sessions` 파일에 0o600 권한으로 - 저장되어 데몬 재시작 후에도 로그인이 유지된다. 기본 24시간 TTL이 있지만, 비루프백 바인딩에서 - 토큰이 평문 HTTP로 전송되므로 네트워크 경로상 노출 창이 "프로세스 종료까지"에서 - "TTL 만료까지"로 넓어진다. `session_ttl_hours = 0`은 그 창을 로그아웃까지로 넓히므로 - **비루프백 바인딩과 같이 쓸 설정이 아니다** — 그래도 막지 않는 것은 무엇이 위협인지 아는 쪽이 - 운영자이기 때문이고, 기본값이 24시간인 것도 같은 이유다(고르지 않은 사람은 고르지 않은 것이다). - 원격 접속은 SSH 터널이나 TLS 프록시로 감싸야 한다. - Windows에서는 파일 권한이 no-op이므로 상태 디렉토리 위치로 통제한다. +- **저장소 루트가 넓어질 수 있다.** 핸들러는 `Repository::discover`로 저장소를 열고 `repo.workdir()` 기준으로 경로를 푼다. `discover`는 상위로 올라가므로, 저장소가 아닌 디렉토리를 서빙하면 (`serve --repo ~/notes`, `$HOME`이 저장소일 때) 브라우징 루트가 `$HOME`으로 넓어진다. traversal은 여전히 불가능하지만 운영자가 지정한 범위보다 넓다. 후속으로 `entry.path`에서 workdir을 파생시켜야 한다. +- **로그인 rate limiter가 프로세스 전역**이라 미인증 요청 3회/분으로 정당한 사용자의 로그인을 잠글 수 있다. 단일 비밀번호 모델의 대가. +- **터미널은 클라이언트 간 격리가 없다.** 연결된 어느 클라이언트든 그 저장소의 아무 pane에 입력·리사이즈·종료할 수 있다. 단일 공유 비밀번호에서는 일관되지만 pane 소유권 개념이 없다는 뜻이다. +- **PTY는 연결이 끊겨도 회수되지 않는다**(재접속 시 세션 유지 목적). 저장소당 최대 8개가 프로세스 수명 동안 남는다. +- **세션 토큰은 디스크에 영속화된다.** `~/.nightcrow/sessions` 파일에 0o600 권한으로 저장되어 데몬 재시작 후에도 로그인이 유지된다. 기본 24시간 TTL이 있지만, 비루프백 바인딩에서 토큰이 평문 HTTP로 전송되므로 네트워크 경로상 노출 창이 "프로세스 종료까지"에서 "TTL 만료까지"로 넓어진다. `session_ttl_hours = 0`은 그 창을 로그아웃까지로 넓히므로 **비루프백 바인딩과 같이 쓸 설정이 아니다** — 그래도 막지 않는 것은 무엇이 위협인지 아는 쪽이 운영자이기 때문이고, 기본값이 24시간인 것도 같은 이유다(고르지 않은 사람은 고르지 않은 것이다). 원격 접속은 SSH 터널이나 TLS 프록시로 감싸야 한다. Windows에서는 파일 권한이 no-op이므로 상태 디렉토리 위치로 통제한다. - **`Secure` 쿠키 플래그 없음.** loopback 기본값에서는 맞지만 `bind`를 바꾸면 평문 HTTP로 토큰이 나간다. -- **HTML 미리보기 프레임은 자기 자신을 다른 곳으로 이동시킬 수 있다.** `allow-scripts`를 준 이상 - 스크립트가 `location`으로 프레임을 외부 URL(자기 소스를 실어 — 그 소스는 파일 작성자가 이미 가진 - 것이다)이나 팬을 채우는 피싱 페이지로 옮기는 것은 CSP로 막을 수 없다(`connect-src`는 연결만, - navigation은 아니다). 프레임은 opaque origin이라 세션·다른 저장소 파일·앱 DOM에는 닿지 못하므로 - 사용자 비밀은 새지 않는다 — 정적 HTML도 이미 가능한 in-frame UI spoofing과 같은 계열의 잔여 - 위험으로 수용한다. 세션을 겨냥한 두 경로(top-level 이동으로 앱 origin 실행, 프레임에서 - `/logout` 자가 이동)는 브라우저가 `Sec-Fetch-Dest`를 보내는 origin에서 그 헤더로 - 닫힌다(`server/preview.rs`, `dispatch.rs`). 브라우저는 이 메타데이터를 신뢰 가능한 - origin(HTTPS·localhost)에서만 보내므로, 평문 HTTP(LAN·Tailscale 주소로 붙는 폰)에서는 - 헤더가 없다 — 게이트는 fail-open이라 그 경로에선 미리보기가 raw로 깨지지 않고 실행 가능한 - 문서를 받으며, 세션을 막는 것은 응답의 CSP `sandbox`(opaque origin) 단독이다. 이는 어차피 - 이 두 이동이 먼저 부딪히는 벽이고, `Sec-Fetch` 게이트는 메타데이터가 있는 곳에 벽을 하나 더 - 세우는 덤이었다. 평문 HTTP 원격 접속은 원래 TLS 프록시로 감싸는 것을 전제한다(위 세션 토큰 항목). +- **HTML 미리보기 프레임은 자기 자신을 다른 곳으로 이동시킬 수 있다.** `allow-scripts`를 준 이상 스크립트가 `location`으로 프레임을 외부 URL(자기 소스를 실어 — 그 소스는 파일 작성자가 이미 가진 것이다)이나 팬을 채우는 피싱 페이지로 옮기는 것은 CSP로 막을 수 없다(`connect-src`는 연결만, navigation은 아니다). 프레임은 opaque origin이라 세션·다른 저장소 파일·앱 DOM에는 닿지 못하므로 사용자 비밀은 새지 않는다 — 정적 HTML도 이미 가능한 in-frame UI spoofing과 같은 계열의 잔여 위험으로 수용한다. 세션을 겨냥한 두 경로(top-level 이동으로 앱 origin 실행, 프레임에서 `/logout` 자가 이동)는 브라우저가 `Sec-Fetch-Dest`를 보내는 origin에서 그 헤더로 닫힌다(`server/preview.rs`, `dispatch.rs`). 브라우저는 이 메타데이터를 신뢰 가능한 origin(HTTPS·localhost)에서만 보내므로, 평문 HTTP(LAN·Tailscale 주소로 붙는 폰)에서는 헤더가 없다 — 게이트는 fail-open이라 그 경로에선 미리보기가 raw로 깨지지 않고 실행 가능한 문서를 받으며, 세션을 막는 것은 응답의 CSP `sandbox`(opaque origin) 단독이다. 이는 어차피 이 두 이동이 먼저 부딪히는 벽이고, `Sec-Fetch` 게이트는 메타데이터가 있는 곳에 벽을 하나 더 세우는 덤이었다. 평문 HTTP 원격 접속은 원래 TLS 프록시로 감싸는 것을 전제한다(위 세션 토큰 항목). ← [Architecture index](../architecture.md) diff --git a/docs/configuration.md b/docs/configuration.md index cd57d4eb..1630a67b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,8 +1,6 @@ # Configuration -Config file: `~/.nightcrow/config.toml` (all fields optional, defaults shown). -nightcrow runs on built-in defaults when the file is absent and never creates it -on its own. To get a starter file, run: +Config file: `~/.nightcrow/config.toml` (all fields optional, defaults shown). nightcrow runs on built-in defaults when the file is absent and never creates it on its own. To get a starter file, run: ```bash nightcrow init # writes a commented ~/.nightcrow/config.toml @@ -101,18 +99,14 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f ## `[shell]` -The shell every terminal pane is spawned with. When the whole section is absent, -the platform default is used: +The shell every terminal pane is spawned with. When the whole section is absent, the platform default is used: | Platform | `program` | `command_args` | |----------|-------------------------------|----------------| | Unix | `$SHELL` env var or `/bin/sh` | `["-lc"]` | | Windows | `%ComSpec%` or `cmd.exe` | `["/C"]` | -`command_args` is the flag list placed *after* the shell name. The command text -is always the last single argv item, so the shell — not us — handles its -quoting/word-splitting. Interpolation like `["-c", "{}"]` is not supported: that -would break the contract that the shell owns quoting. +`command_args` is the flag list placed *after* the shell name. The command text is always the last single argv item, so the shell — not us — handles its quoting/word-splitting. Interpolation like `["-c", "{}"]` is not supported: that would break the contract that the shell owns quoting. ```toml [shell] @@ -122,11 +116,7 @@ would break the contract that the shell owns quoting. ## `[[startup_command]]` -Each entry opens its own terminal pane at launch and runs `command` immediately -(via the configured shell). Up to 8 entries combined with CLI `--exec` — 8 -matches the ` 3`–`9`,`0` jump keys, so every startup pane is reachable by -a direct key. This caps only the startup batch; open more anytime with -` t`. With no entries, nightcrow opens a single empty shell. +Each entry opens its own terminal pane at launch and runs `command` immediately (via the configured shell). Up to 8 entries combined with CLI `--exec` — 8 matches the ` 3`–`9`,`0` jump keys, so every startup pane is reachable by a direct key. This caps only the startup batch; open more anytime with ` t`. With no entries, nightcrow opens a single empty shell. ```toml [[startup_command]] @@ -142,9 +132,7 @@ command = "cargo test --watch" ## `[[plugin]]` -External plugin processes — see [Plugins](plugins.md). Up to 8 entries, names -unique. Nothing runs unless an entry exists **and** `enabled = true` **and** -either a pane opted in or `watch_on_signal` is set. +External plugin processes — see [Plugins](plugins.md). Up to 8 entries, names unique. Nothing runs unless an entry exists **and** `enabled = true` **and** either a pane opted in or `watch_on_signal` is set. ```toml [[plugin]] @@ -166,14 +154,10 @@ NIGHTCROW_RECOVERY_LOG = "info" ## Reloading the config -Editing `config.toml` normally means restarting the session — which kills every -pane, including whatever an agent CLI was in the middle of. Two of the tables can -be re-read instead, without stopping anything: +Editing `config.toml` normally means restarting the session — which kills every pane, including whatever an agent CLI was in the middle of. Two of the tables can be re-read instead, without stopping anything: - **In the TUI**: ` u`. The result appears on the notice row. -- **In the browser**: the ⟳ button in the header, next to sign out. It reloads - the *config*, not the page — nothing on screen changes, and the result comes - back as a toast. +- **In the browser**: the ⟳ button in the header, next to sign out. It reloads the *config*, not the page — nothing on screen changes, and the result comes back as a toast. | Table | When it takes effect | | --- | --- | @@ -183,31 +167,12 @@ be re-read instead, without stopping anything: Notes: -- **Nothing half-applies.** The whole file is parsed and validated first, so a - typo anywhere leaves the session exactly as it was, and the message names the - key that was wrong. -- **A missing file is refused** rather than read as "nothing is configured" — - otherwise deleting the file and reloading would be a quiet way to stop every - plugin. -- Panes opened with `--exec` are kept: they are not in the file, so a reload - merges them back where a restart would have put them. -- Disabling a plugin and enabling it again lands where enabling it the first time - would — the pane's opt-in survives, so `enabled` means the same thing whichever - way it was last flipped. -- **Restarting a plugin discards whatever it was in the middle of.** A plugin's - state lives in its process, so replacing that process loses it — for - `nightcrow-recovery` a pane parked on a quota reset hours away simply stops - being watched, and nothing will resume it. The plugin logs how many panes it - abandoned on the way out. This only happens when you change *that plugin's* own - `command`, `args` or `env`; every other edit leaves a waiting one running. -- A pane whose process had already exited and whose slot was being held for a - relaunch gives that slot up when its plugin is stopped or replaced. The - successor is never handed the pane's token, so nothing could honour the hold; - the countdown ends instead of running out its window. -- If the result says **`(1 was too busy to be told)`**, that project kept the - plugins it had. Its terminals were too far behind to take the request, and - waiting on one project would have held up every other. Nothing else about the - reload is affected — reload again once it has caught up. The server log names - the project. +- **Nothing half-applies.** The whole file is parsed and validated first, so a typo anywhere leaves the session exactly as it was, and the message names the key that was wrong. +- **A missing file is refused** rather than read as "nothing is configured" — otherwise deleting the file and reloading would be a quiet way to stop every plugin. +- Panes opened with `--exec` are kept: they are not in the file, so a reload merges them back where a restart would have put them. +- Disabling a plugin and enabling it again lands where enabling it the first time would — the pane's opt-in survives, so `enabled` means the same thing whichever way it was last flipped. +- **Restarting a plugin discards whatever it was in the middle of.** A plugin's state lives in its process, so replacing that process loses it — for `nightcrow-recovery` a pane parked on a quota reset hours away simply stops being watched, and nothing will resume it. The plugin logs how many panes it abandoned on the way out. This only happens when you change *that plugin's* own `command`, `args` or `env`; every other edit leaves a waiting one running. +- A pane whose process had already exited and whose slot was being held for a relaunch gives that slot up when its plugin is stopped or replaced. The successor is never handed the pane's token, so nothing could honour the hold; the countdown ends instead of running out its window. +- If the result says **`(1 was too busy to be told)`**, that project kept the plugins it had. Its terminals were too far behind to take the request, and waiting on one project would have held up every other. Nothing else about the reload is affected — reload again once it has caught up. The server log names the project. Design notes: [Architecture → Session](architecture/session.md#config-reload-webviewerreloadrs). diff --git a/docs/decisions.md b/docs/decisions.md index 5a1defd5..01bcdfef 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,228 +1,125 @@ # 설계 결정 이력 -확정된 설계는 [`architecture.md`](architecture.md)에, 사용법은 [`../README.md`](../README.md)과 -`docs/`의 사용자 문서에 있다. 이 문서는 **왜 그렇게 갔는지** — 특히 계획과 갈린 지점과 -검토 후 접은 대안 — 만 남긴다. +확정된 설계는 [`architecture.md`](architecture.md)에, 사용법은 [`../README.md`](../README.md)과 `docs/`의 사용자 문서에 있다. 이 문서는 **왜 그렇게 갔는지** — 특히 계획과 갈린 지점과 검토 후 접은 대안 — 만 남긴다. ## 세션 데몬 (2026) ### 왜 클라이언트 렌더인가 -가능한 구조가 둘이었다. 서버 렌더(데몬이 ratatui로 한 장 그려 ANSI를 민다)는 클라이언트가 -수백 줄로 끝나지만 **그리드가 하나뿐이라 디스플레이별 크기가 불가능**하다. 모두가 같은 크기를 -강제로 공유하게 된다. "디스플레이 종류·사이즈에 따라 재렌더링"이 목표에 있었으므로 성립하지 -않는다. +가능한 구조가 둘이었다. 서버 렌더(데몬이 ratatui로 한 장 그려 ANSI를 민다)는 클라이언트가 수백 줄로 끝나지만 **그리드가 하나뿐이라 디스플레이별 크기가 불가능**하다. 모두가 같은 크기를 강제로 공유하게 된다. "디스플레이 종류·사이즈에 따라 재렌더링"이 목표에 있었으므로 성립하지 않는다. -그리고 클라이언트 렌더 프로토콜은 이미 있었다 — 웹 뷰어가 쓰던 것이 그것이다. 그래서 이 작업은 -새 데몬을 만드는 일이 아니라 `serve`를 세션 데몬으로 승격시키고 TUI를 그 클라이언트로 -돌려세우는 일이 됐다. 같은 이유로 웹 미러는 존재 이유를 잃고 제거됐다 — 브라우저가 화면 반사 -대신 네이티브 프론트엔드로 같은 세션에 붙는다. +그리고 클라이언트 렌더 프로토콜은 이미 있었다 — 웹 뷰어가 쓰던 것이 그것이다. 그래서 이 작업은 새 데몬을 만드는 일이 아니라 `serve`를 세션 데몬으로 승격시키고 TUI를 그 클라이언트로 돌려세우는 일이 됐다. 같은 이유로 웹 미러는 존재 이유를 잃고 제거됐다 — 브라우저가 화면 반사 대신 네이티브 프론트엔드로 같은 세션에 붙는다. ### 지금도 구속하는 제약 - **async 런타임 무도입.** 동기 스레드 모델을 유지한다. - **git 온디맨드 읽기는 클라이언트 로컬.** diff/file/tree/log는 UI 스레드에서 동기로 읽는다. - 새 프로토콜을 발명하지 않고 뷰어가 쓰던 메시지 타입을 전송만 바꿔 재사용한다. -- 각 커밋이 빌드·테스트를 통과하고 **쓸 수 있는 상태**여야 한다 — 중간에 TUI를 못 쓰는 기간이 - 없도록 단계를 배치했다. +- 각 커밋이 빌드·테스트를 통과하고 **쓸 수 있는 상태**여야 한다 — 중간에 TUI를 못 쓰는 기간이 없도록 단계를 배치했다. ### 접은 대안: 원격 attach (= git 데이터를 프로토콜에 올리기) -다른 머신의 데몬에 TUI로 붙는 것은 목표에서 뺐다. 그래서 git 데이터를 프로토콜로 옮기지 않는다. -옮기려면 `app/`의 "선택이 바뀌면 그 자리에서 동기로 읽는다"는 전제를 전부 pending 상태를 갖는 -비동기 요청으로 뒤집어야 하는데(`diff_load`, `commit_log_fetch`, `tree`, `file_view_load`, -`snapshot_io`), 로컬에서는 양쪽이 같은 디스크를 읽어 1초 안에 수렴하므로 사용자에게 보이는 -이득이 거의 없다. 비용은 이 프로젝트에서 제일 크다. +다른 머신의 데몬에 TUI로 붙는 것은 목표에서 뺐다. 그래서 git 데이터를 프로토콜로 옮기지 않는다. 옮기려면 `app/`의 "선택이 바뀌면 그 자리에서 동기로 읽는다"는 전제를 전부 pending 상태를 갖는 비동기 요청으로 뒤집어야 하는데(`diff_load`, `commit_log_fetch`, `tree`, `file_view_load`, `snapshot_io`), 로컬에서는 양쪽이 같은 디스크를 읽어 1초 안에 수렴하므로 사용자에게 보이는 이득이 거의 없다. 비용은 이 프로젝트에서 제일 크다. ### 접은 대안: stale 소켓 connect로 단일 인스턴스 판정 -계획에는 "남은 소켓에 connect해서 살아 있는 데몬인지 본다"로 적혀 있었다. **macOS에서는 -리스너가 닫힌 소켓에도 connect가 성공할 수 있어** 판정이 성립하지 않았다(테스트가 세 번에 한 -번꼴로 실패). `flock`은 `kill -9`에도 커널이 해제하므로 정확하다 — 그래서 `libc`를 직접 -의존성으로 올렸다(std가 노출하지 않는 유일한 호출). +계획에는 "남은 소켓에 connect해서 살아 있는 데몬인지 본다"로 적혀 있었다. **macOS에서는 리스너가 닫힌 소켓에도 connect가 성공할 수 있어** 판정이 성립하지 않았다(테스트가 세 번에 한 번꼴로 실패). `flock`은 `kill -9`에도 커널이 해제하므로 정확하다 — 그래서 `libc`를 직접 의존성으로 올렸다(std가 노출하지 않는 유일한 호출). ### 접은 대안: 데몬화 크레이트, 그리고 fork -데몬화 크레이트는 표준 없이 파편화돼 있어(daemonize/daemonize2/daemonizr/fork) 채택하지 -않았다. `-d`는 **fork가 아니라 재exec + `setsid`**다. 스레드가 도는 프로세스에서 fork하면 -자식은 스레드 하나와 임의 상태의 락들을 물려받는다 — 데몬화 크레이트들이 조심스러워하는 것이 -정확히 그것이다. 새 사본을 exec하면 물려받을 상태가 없다. +데몬화 크레이트는 표준 없이 파편화돼 있어(daemonize/daemonize2/daemonizr/fork) 채택하지 않았다. `-d`는 **fork가 아니라 재exec + `setsid`**다. 스레드가 도는 프로세스에서 fork하면 자식은 스레드 하나와 임의 상태의 락들을 물려받는다 — 데몬화 크레이트들이 조심스러워하는 것이 정확히 그것이다. 새 사본을 exec하면 물려받을 상태가 없다. ### 접은 대안: 입력마다 PTY 크기 소유권 이전 -PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이라(자식은 `TIOCGWINSZ`로 들은 폭에 맞춰 -출력을 만든다) pane의 셀 크기는 단일 값이어야 한다. 클라이언트마다 자기 크기로 에뮬레이터를 -돌리는 방식은 줄 단위 출력에만 통하고, alternate screen을 쓰는 풀스크린 TUI(Claude Code, -Codex, vim)에서는 두 화면이 서로 다른 쓰레기로 갈라진다 — 그게 이 앱의 주 용도다. +PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이라(자식은 `TIOCGWINSZ`로 들은 폭에 맞춰 출력을 만든다) pane의 셀 크기는 단일 값이어야 한다. 클라이언트마다 자기 크기로 에뮬레이터를 돌리는 방식은 줄 단위 출력에만 통하고, alternate screen을 쓰는 풀스크린 TUI(Claude Code, Codex, vim)에서는 두 화면이 서로 다른 쓰레기로 갈라진다 — 그게 이 앱의 주 용도다. -그래서 tmux의 `window-size latest` 모델을 택했다. 여기서 **입력마다 소유권을 옮기는 대안은 -기각**했다. 폰으로 잠깐 확인하는 흔한 동작이 곧바로 전체 repaint를 유발해, 제일 가벼운 행동이 -제일 비싼 행동이 된다. 부수 효과로 비소유 클라이언트가 곧 관전자가 되므로 별도의 관전 모드를 -만들 필요가 없어졌다. +그래서 tmux의 `window-size latest` 모델을 택했다. 여기서 **입력마다 소유권을 옮기는 대안은 기각**했다. 폰으로 잠깐 확인하는 흔한 동작이 곧바로 전체 repaint를 유발해, 제일 가벼운 행동이 제일 비싼 행동이 된다. 부수 효과로 비소유 클라이언트가 곧 관전자가 되므로 별도의 관전 모드를 만들 필요가 없어졌다. ### 뒤집힌 결정: accent -초안은 accent를 공유로 뒀다가 **클라이언트별로 뺐다** — 세션 사실이 아니라 표시 취향이고, -TUI의 accent는 저장소별이라(색으로 탭을 구별하는 것이 그 기능의 목적) 세션 전역 값 하나로 -만들면 그것이 사라진다는 이유였다. +초안은 accent를 공유로 뒀다가 **클라이언트별로 뺐다** — 세션 사실이 아니라 표시 취향이고, TUI의 accent는 저장소별이라(색으로 탭을 구별하는 것이 그 기능의 목적) 세션 전역 값 하나로 만들면 그것이 사라진다는 이유였다. -**그 뒤 다시 뒤집혔다.** 한 세션을 TUI와 브라우저로 나란히 두면 같은 세션이 두 색으로 보였고, -어느 쪽이 세션의 색이냐에 답할 수 있는 값이 없었다. 지금 accent는 세션 전역 값 하나다. 저장소별 -색이 대신하던 "지금 어느 프로젝트인가"는 탭 이름과 활성 탭 강조가 답한다. 현재 경계는 -[`architecture.md`](architecture.md)의 "세션 공유" 절이 기준이다. +**그 뒤 다시 뒤집혔다.** 한 세션을 TUI와 브라우저로 나란히 두면 같은 세션이 두 색으로 보였고, 어느 쪽이 세션의 색이냐에 답할 수 있는 값이 없었다. 지금 accent는 세션 전역 값 하나다. 저장소별 색이 대신하던 "지금 어느 프로젝트인가"는 탭 이름과 활성 탭 강조가 답한다. 현재 경계는 [`architecture.md`](architecture.md)의 "세션 공유" 절이 기준이다. -활성 프로젝트도 D단계에서는 클라이언트별로 구현돼 있었고 뷰어 코드에도 같은 판단이 박혀 -있었지만(`resolveActiveRepo`) 공유로 확정했다. 대가는 알고 택했다 — 폰에서 탭을 바꾸면 노트북 -화면도 옮겨간다. 대신 "브라우저와 TUI가 서로 다른 프로젝트를 보여주는데 둘 다 정상"인 상태가 -없어진다. +활성 프로젝트도 D단계에서는 클라이언트별로 구현돼 있었고 뷰어 코드에도 같은 판단이 박혀 있었지만(`resolveActiveRepo`) 공유로 확정했다. 대가는 알고 택했다 — 폰에서 탭을 바꾸면 노트북 화면도 옮겨간다. 대신 "브라우저와 TUI가 서로 다른 프로젝트를 보여주는데 둘 다 정상"인 상태가 없어진다. ### 계획과 갈린 지점 -- **B단계(빈 `attach` 별칭 + `application/` → `client/` 이동)는 C·D에 흡수했다.** 아직 - 클라이언트가 아닌 것에 클라이언트 이름을 붙이는 선반영이라, 실제 소비자가 생기는 시점에 - 만들었다. -- **순서를 D → F → E로 바꿨다.** F를 E 뒤에 두면 E 내내 `TerminalState`가 로컬 PTY와 원격 hub - 양쪽을 다뤄야 한다. F를 먼저 하면 E는 최종 형태 하나만 본다. 대가는 E가 끝날 때까지 detach가 - 터미널을 죽인다는 것인데, 그건 지금도 없는 기능이라 잃는 것이 아니라 아직 얻지 못한 것이었다. -- **`--repo`는 남기지 않고 지웠다.** 같은 이름이 자리마다 다른 뜻이었다 — TUI에서는 기억된 - 목록을 *대체*하고, 데몬과 attach에서는 *추가*했다. 여러 클라이언트가 공유하는 세션에서 - 저장소를 여는 자리는 안쪽 하나뿐이라 둘 중 하나로 통일하는 대신 없앴다. 밖에서 세션을 미리 - 채우는 요구가 생기면 argv가 아니라 config에 둔다. -- **알림(callback)이 아니라 관측.** 데몬은 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 - 브로드캐스트한다. 알림 방식은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 이 - 버그로 다시 나타난다. +- **B단계(빈 `attach` 별칭 + `application/` → `client/` 이동)는 C·D에 흡수했다.** 아직 클라이언트가 아닌 것에 클라이언트 이름을 붙이는 선반영이라, 실제 소비자가 생기는 시점에 만들었다. +- **순서를 D → F → E로 바꿨다.** F를 E 뒤에 두면 E 내내 `TerminalState`가 로컬 PTY와 원격 hub 양쪽을 다뤄야 한다. F를 먼저 하면 E는 최종 형태 하나만 본다. 대가는 E가 끝날 때까지 detach가 터미널을 죽인다는 것인데, 그건 지금도 없는 기능이라 잃는 것이 아니라 아직 얻지 못한 것이었다. +- **`--repo`는 남기지 않고 지웠다.** 같은 이름이 자리마다 다른 뜻이었다 — TUI에서는 기억된 목록을 *대체*하고, 데몬과 attach에서는 *추가*했다. 여러 클라이언트가 공유하는 세션에서 저장소를 여는 자리는 안쪽 하나뿐이라 둘 중 하나로 통일하는 대신 없앴다. 밖에서 세션을 미리 채우는 요구가 생기면 argv가 아니라 config에 둔다. +- **알림(callback)이 아니라 관측.** 데몬은 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 브로드캐스트한다. 알림 방식은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 이 버그로 다시 나타난다. ### G단계: 측정하고 다르게 고쳤다 -계획은 "TUI가 데몬 스냅샷을 구독"(중복 제거)이었다. 먼저 쟀다 — `git status` 한 번은 파일 -260개에서 3 ms, 1만 개에서 23 ms, 5만 개에서 129 ms. 폴링 수는 클라이언트 수에 비례하므로 -5만 파일 트리 + 4탭 TUI 하나면 초당 516 ms인데 그중 중복은 129 ms뿐이었다. **주범은 중복이 -아니라 "안 바뀌었는데도 매초 걷는 것"이었다.** +계획은 "TUI가 데몬 스냅샷을 구독"(중복 제거)이었다. 먼저 쟀다 — `git status` 한 번은 파일 260개에서 3 ms, 1만 개에서 23 ms, 5만 개에서 129 ms. 폴링 수는 클라이언트 수에 비례하므로 5만 파일 트리 + 4탭 TUI 하나면 초당 516 ms인데 그중 중복은 129 ms뿐이었다. **주범은 중복이 아니라 "안 바뀌었는데도 매초 걷는 것"이었다.** -그래서 (1) 구독자 없는 저장소는 걷지도 감시하지도 않고, (2) 읽기를 변화 구동으로 바꿨다. 유휴 -비용이 초당 129 ms → 13 ms로 떨어졌고 변화 감지는 최대 1초 → 즉시가 됐다. +그래서 (1) 구독자 없는 저장소는 걷지도 감시하지도 않고, (2) 읽기를 변화 구동으로 바꿨다. 유휴 비용이 초당 129 ms → 13 ms로 떨어졌고 변화 감지는 최대 1초 → 즉시가 됐다. -**안 한 것**: 데몬 스냅샷 구독. 남는 중복은 유휴에서 10초에 한 번뿐이고, 닫으려면 주기적 git -데이터를 attach 프로토콜에 올려(위에서 피한 그것) TUI가 자기 뷰를 만드는 두 번째 경로를 갖게 -된다 — 가장 많이 쓰는 화면에 단일 실패점을 만드는 값이다. +**안 한 것**: 데몬 스냅샷 구독. 남는 중복은 유휴에서 10초에 한 번뿐이고, 닫으려면 주기적 git 데이터를 attach 프로토콜에 올려(위에서 피한 그것) TUI가 자기 뷰를 만드는 두 번째 경로를 갖게 된다 — 가장 많이 쓰는 화면에 단일 실패점을 만드는 값이다. ### 넣지 않은 것 -- **재연결.** 연결이 끊기면 TUI가 alternate screen을 정상적으로 벗고 이유 한 줄과 함께 - 비정상 종료한다. 메시지는 "세션이 사라졌다"가 아니다 — 데몬이 살아 있는데 이 연결만 끊긴 - 경우(뒤처진 클라이언트를 데몬이 끊는 경로)가 있으므로 확실한 것만 말하고 재attach를 권한다. - 넣으려면 연결을 재접속 가능한 채널로 바꾸고 재접속마다 pane을 버리고 리플레이를 다시 받아야 - 한다(그러지 않으면 스크롤백이 에뮬레이터에 두 번 들어간다). 지금 설계를 막지 않으므로 - 필요해지면 그때 올린다. +- **재연결.** 연결이 끊기면 TUI가 alternate screen을 정상적으로 벗고 이유 한 줄과 함께 비정상 종료한다. 메시지는 "세션이 사라졌다"가 아니다 — 데몬이 살아 있는데 이 연결만 끊긴 경우(뒤처진 클라이언트를 데몬이 끊는 경로)가 있으므로 확실한 것만 말하고 재attach를 권한다. 넣으려면 연결을 재접속 가능한 채널로 바꾸고 재접속마다 pane을 버리고 리플레이를 다시 받아야 한다(그러지 않으면 스크롤백이 에뮬레이터에 두 번 들어간다). 지금 설계를 막지 않으므로 필요해지면 그때 올린다. - **named session.** 사용자당 데몬 하나를 전제한다. -- **스크롤백 상한 변경.** 실측 결과 평범한 출력은 클라이언트의 1000줄을 다 채우고, 줄당 - ~262바이트를 넘는 escape-heavy 출력만 그보다 얕다. 경계 양쪽을 테스트로 고정하고 - (`terminal/tests/scrollback_depth.rs`) 상한은 그대로 뒀다. +- **스크롤백 상한 변경.** 실측 결과 평범한 출력은 클라이언트의 1000줄을 다 채우고, 줄당 ~262바이트를 넘는 escape-heavy 출력만 그보다 얕다. 경계 양쪽을 테스트로 고정하고 (`terminal/tests/scrollback_depth.rs`) 상한은 그대로 뒀다. ## 웹 뷰어 ### 계획과 갈린 지점 -- **shadcn/ui 미채택.** 계획 자체가 "기본 톤을 TUI 밀도로 재조정해야 한다"고 적고 있었는데, - 실제 UI가 커스텀 고밀도 패널이라 덮어쓸 것이 쌓을 것보다 많았다. Tailwind가 토큰을 직접 든다. -- **"연결 수명" 단계는 미러를 건드리지 않았다.** `SseStream`이 자기 헤드를 쓰고 소켓을 소유하는 - 구조로 해소돼, 소비자 없는 상태에서 미러 응답 경로를 고칠 이유가 없었다. -- **경로 검증 위치가 바뀌었다 — 이게 실제 버그였다.** 계획은 "tree/file/commit 전 엔드포인트가 - 검증기를 공유"라고만 적었는데, 그것을 **라우트별로 구현하면 새 라우트가 빠뜨린다.** 실제로 - `/api/diff`가 `../../etc/passwd`를 받아들였다 — `load_file_diff`는 경로를 파일이 아니라 git - pathspec으로 쓰므로 검증기에 닿지 않고, 빈 hunk와 함께 공격자 경로를 되돌려줬다. 검증은 - dispatch 한 지점(`with_repo`)에 있어야 "어떤 로더를 부르느냐"와 무관하게 안전하다. 이 규칙은 - 지금도 구속한다: **새 repo 라우트는 자기 경로 검증을 하지 않는다.** +- **shadcn/ui 미채택.** 계획 자체가 "기본 톤을 TUI 밀도로 재조정해야 한다"고 적고 있었는데, 실제 UI가 커스텀 고밀도 패널이라 덮어쓸 것이 쌓을 것보다 많았다. Tailwind가 토큰을 직접 든다. +- **"연결 수명" 단계는 미러를 건드리지 않았다.** `SseStream`이 자기 헤드를 쓰고 소켓을 소유하는 구조로 해소돼, 소비자 없는 상태에서 미러 응답 경로를 고칠 이유가 없었다. +- **경로 검증 위치가 바뀌었다 — 이게 실제 버그였다.** 계획은 "tree/file/commit 전 엔드포인트가 검증기를 공유"라고만 적었는데, 그것을 **라우트별로 구현하면 새 라우트가 빠뜨린다.** 실제로 `/api/diff`가 `../../etc/passwd`를 받아들였다 — `load_file_diff`는 경로를 파일이 아니라 git pathspec으로 쓰므로 검증기에 닿지 않고, 빈 hunk와 함께 공격자 경로를 되돌려줬다. 검증은 dispatch 한 지점(`with_repo`)에 있어야 "어떤 로더를 부르느냐"와 무관하게 안전하다. 이 규칙은 지금도 구속한다: **새 repo 라우트는 자기 경로 검증을 하지 않는다.** ### 왜 미러가 아니라 별도 서비스인가 -미러는 TUI 그리드를 그대로 반사해 `App`+`ui`+`input`을 통째로 재사용했다. 뷰어는 그 계층을 -하나도 쓰지 않고 하부 데이터/PTY 계층만 공유하는 두 번째 프론트엔드다. 그래서 미러의 -"무빌드·바닐라·`include_str!`" 제약을 상속하지 않는다 — 별도 서비스엔 깰 불변식이 없으므로 -React/Vite 빌드가 정상적 선택이었다. +미러는 TUI 그리드를 그대로 반사해 `App`+`ui`+`input`을 통째로 재사용했다. 뷰어는 그 계층을 하나도 쓰지 않고 하부 데이터/PTY 계층만 공유하는 두 번째 프론트엔드다. 그래서 미러의 "무빌드·바닐라·`include_str!`" 제약을 상속하지 않는다 — 별도 서비스엔 깰 불변식이 없으므로 React/Vite 빌드가 정상적 선택이었다. -같은 이유로 터미널을 **독립 세션**으로 뒀다(당시 기준). TUI와 같은 세션 터미널은 PTY가 `App`에 -있어야 해서 "서버는 App을 참조하지 않는다"는 전제와 헤드리스 모드가 깨졌다. 이 판단은 이후 세션 -데몬이 PTY 소유권을 데몬으로 올리면서 자연스럽게 해소됐다. +같은 이유로 터미널을 **독립 세션**으로 뒀다(당시 기준). TUI와 같은 세션 터미널은 PTY가 `App`에 있어야 해서 "서버는 App을 참조하지 않는다"는 전제와 헤드리스 모드가 깨졌다. 이 판단은 이후 세션 데몬이 PTY 소유권을 데몬으로 올리면서 자연스럽게 해소됐다. ### 접은 대안 (그 밖) -- **스레드 로컬 `Repository` 캐시.** 서버가 연결마다 스레드를 새로 뜨고 요청 처리 후 종료하므로 - 캐시가 그 스레드와 함께 버려져 이득이 없다. -- **Vite `dist/`를 커밋하지 않기.** `cargo install nightcrow`이 Node 없이 동작해야 한다. - `build.rs`에서 npm을 부르면 crates.io 설치 사용자가 깨지고, cargo feature로 가르면 CI - 매트릭스와 조건부 컴파일이 는다. 비용인 산출물 diff 노이즈는 `.gitattributes`의 - `linguist-generated`로 완화한다. -- **미러의 팬아웃(`Shared`/`ClientMsg`/`Buffer`)을 `web/common`으로 올리기.** 그 팬아웃은 - 터미널·그리드 전용이라 뷰어의 JSON/SSE/터미널과 일반화되지 않는다. 공유는 안정적 - 프리미티브만(auth, 세션 저장, rate-limit, 요청/응답 파싱). -- **커서 기반 log 페이징.** 마지막 커밋 oid에서 다시 walk하면 병합 히스토리에서 그 커밋의 - *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. - anchor(`from`) + `skip`은 같은 walk의 offset이라 그 문제가 없다. 현재 동작과 알려진 대가는 - [`architecture.md`](architecture.md)의 Web Viewer 절에 있다. -- **TUI의 split-view/fullscreen/swap/visible-window 로직 재사용.** 웹은 터미널 탭/기본 그리드의 - 자체 단순 모델로 갔다. +- **스레드 로컬 `Repository` 캐시.** 서버가 연결마다 스레드를 새로 뜨고 요청 처리 후 종료하므로 캐시가 그 스레드와 함께 버려져 이득이 없다. +- **Vite `dist/`를 커밋하지 않기.** `cargo install nightcrow`이 Node 없이 동작해야 한다. `build.rs`에서 npm을 부르면 crates.io 설치 사용자가 깨지고, cargo feature로 가르면 CI 매트릭스와 조건부 컴파일이 는다. 비용인 산출물 diff 노이즈는 `.gitattributes`의 `linguist-generated`로 완화한다. +- **미러의 팬아웃(`Shared`/`ClientMsg`/`Buffer`)을 `web/common`으로 올리기.** 그 팬아웃은 터미널·그리드 전용이라 뷰어의 JSON/SSE/터미널과 일반화되지 않는다. 공유는 안정적 프리미티브만(auth, 세션 저장, rate-limit, 요청/응답 파싱). +- **커서 기반 log 페이징.** 마지막 커밋 oid에서 다시 walk하면 병합 히스토리에서 그 커밋의 *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. anchor(`from`) + `skip`은 같은 walk의 offset이라 그 문제가 없다. 현재 동작과 알려진 대가는 [`architecture.md`](architecture.md)의 Web Viewer 절에 있다. +- **TUI의 split-view/fullscreen/swap/visible-window 로직 재사용.** 웹은 터미널 탭/기본 그리드의 자체 단순 모델로 갔다. ## repo 다이얼로그 경로 탐색 ### 접은 대안: 셸을 PTY로 띄우기 -`bash --norc -c 'read -e -p ...'`(readline)이나 zsh `vared`를 PTY로 띄우면 완성이 공짜로 -따라온다. 접은 이유: +`bash --norc -c 'read -e -p ...'`(readline)이나 zsh `vared`를 PTY로 띄우면 완성이 공짜로 따라온다. 접은 이유: -- **Windows에 대응 프리미티브가 없다.** PowerShell `Read-Host`는 완성이 없고(PSReadLine은 - 대화형 호스트 루프 전용), cmd `set /p`도 없다. Windows를 목표로 두는 순간 네이티브 완성기를 - 어차피 써야 하므로 셸은 *대체*가 아니라 *추가* 경로가 된다. +- **Windows에 대응 프리미티브가 없다.** PowerShell `Read-Host`는 완성이 없고(PSReadLine은 대화형 호스트 루프 전용), cmd `set /p`도 없다. Windows를 목표로 두는 순간 네이티브 완성기를 어차피 써야 하므로 셸은 *대체*가 아니라 *추가* 경로가 된다. - 결과 회수가 PTY 스트림 하나뿐이라 sentinel/임시 파일 프로토콜이 필요하다. -- readline 후보 목록은 여러 줄 + "Display all N possibilities?"를 뿜어 hint bar 1줄에 안 들어가고, - PTY 그리드 렌더 영역을 새로 만들어야 한다. +- readline 후보 목록은 여러 줄 + "Display all N possibilities?"를 뿜어 hint bar 1줄에 안 들어가고, PTY 그리드 렌더 영역을 새로 만들어야 한다. - `$SHELL`을 그대로 쓰면 rc 오염·시작 지연·rc가 입력 대기 시 먹통 리스크가 붙는다. `std::fs::read_dir` 기반 네이티브 구현은 새 의존성 0, `cfg(windows)` 분기 0으로 같은 체감을 준다. ### 접은 대안: 기존 트리 인프라 재사용 -`ViewMode::Tree` 자산을 쓰고 싶었지만 대부분 못 썼다. `git::tree::read_children`은 -`git2::Repository`가 필수이고 **repo-relative** 경로만 받으며 `resolve_in_workdir`이 워크트리 -밖 경로와 심볼릭 링크를 거부한다 — 피커는 *어떤 repo에도 속하지 않는* 경로를 돌아다녀야 하고 -**프로젝트 0개 상태**에서도 떠야 한다. 그 함수가 막으려고 만들어진 것이 정확히 피커의 일이다. -`TreeView`는 `App` 소유(프로젝트별)이고 search index / show_set / row_width_cache가 -repo-relative 전용이며, `tree_list::render`는 `&App`·`app.focus`에 의존해 빈 화면에서 호출조차 -안 된다. 실제 재사용은 `render_selectable_list` 하나였다. +`ViewMode::Tree` 자산을 쓰고 싶었지만 대부분 못 썼다. `git::tree::read_children`은 `git2::Repository`가 필수이고 **repo-relative** 경로만 받으며 `resolve_in_workdir`이 워크트리 밖 경로와 심볼릭 링크를 거부한다 — 피커는 *어떤 repo에도 속하지 않는* 경로를 돌아다녀야 하고 **프로젝트 0개 상태**에서도 떠야 한다. 그 함수가 막으려고 만들어진 것이 정확히 피커의 일이다. `TreeView`는 `App` 소유(프로젝트별)이고 search index / show_set / row_width_cache가 repo-relative 전용이며, `tree_list::render`는 `&App`·`app.focus`에 의존해 빈 화면에서 호출조차 안 된다. 실제 재사용은 `render_selectable_list` 하나였다. ### 계획과 갈린 지점 -- **진입 키는 `Ctrl+T`가 아니라 `↓`다.** `T` 니모닉이 ` t`(새 터미널)와 겹쳐 - "충돌하지 않는다"를 설명해야 했는데, 설명이 필요한 키는 이미 진 것이다. 다이얼로그의 다른 키가 - 전부 bare인 것과도 맞고, 필드의 수평 키가 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 - 있었다. 후보 목록이 떠 있을 때의 두 번째 `Tab`도 같은 곳으로 승격한다. -- **hint 행에 키 legend를 붙였다** (계획에 없던 항목). 다이얼로그가 hint legend를 통째로 입력 - 줄로 대체해서 `Tab` 완성조차 화면에 안 나오고 있었다. 진입 키를 아무리 잘 골라도 광고할 자리가 - 없으면 못 찾는다. (이후 입력이 notice 행의 repo 헤더 자리로 올라가면서 legend가 hint 행을 - 통째로 갖게 됐다 — [architecture/ui.md](architecture/ui.md)의 저장소 열기 다이얼로그 절.) -- **상태는 `BTreeSet` + children 캐시가 아니라 평면 row 리스트다.** 확장이 자식을 부모 뒤에 - splice하고 접기가 아래 깊은 row를 drain하면 선택이 화면 인덱스 그대로여서 visible_rows 계산도 - 캐시 무효화도 필요 없다. 계획이 트리 뷰의 구조를 따라가려 했지만 그쪽 복잡도는 repo-relative - 검색 인덱스에서 온 것이고 브라우저에는 없다. -- **플로팅 팝업이 아니라 body 영역 전체.** `src/ui/`에 팝업/오버레이 인프라가 전혀 없어서 - (`Clear` 위젯도 centered-rect 헬퍼도 없다) 떠 있는 박스는 이 프로젝트 최초의 플로팅 UI가 되고 - 마우스 캡처가 기본 on이라 `hit_test.rs`에 새 히트 영역을 끼워야 한다. +- **진입 키는 `Ctrl+T`가 아니라 `↓`다.** `T` 니모닉이 ` t`(새 터미널)와 겹쳐 "충돌하지 않는다"를 설명해야 했는데, 설명이 필요한 키는 이미 진 것이다. 다이얼로그의 다른 키가 전부 bare인 것과도 맞고, 필드의 수평 키가 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 있었다. 후보 목록이 떠 있을 때의 두 번째 `Tab`도 같은 곳으로 승격한다. +- **hint 행에 키 legend를 붙였다** (계획에 없던 항목). 다이얼로그가 hint legend를 통째로 입력 줄로 대체해서 `Tab` 완성조차 화면에 안 나오고 있었다. 진입 키를 아무리 잘 골라도 광고할 자리가 없으면 못 찾는다. (이후 입력이 notice 행의 repo 헤더 자리로 올라가면서 legend가 hint 행을 통째로 갖게 됐다 — [architecture/ui.md](architecture/ui.md)의 저장소 열기 다이얼로그 절.) +- **상태는 `BTreeSet` + children 캐시가 아니라 평면 row 리스트다.** 확장이 자식을 부모 뒤에 splice하고 접기가 아래 깊은 row를 drain하면 선택이 화면 인덱스 그대로여서 visible_rows 계산도 캐시 무효화도 필요 없다. 계획이 트리 뷰의 구조를 따라가려 했지만 그쪽 복잡도는 repo-relative 검색 인덱스에서 온 것이고 브라우저에는 없다. +- **플로팅 팝업이 아니라 body 영역 전체.** `src/ui/`에 팝업/오버레이 인프라가 전혀 없어서 (`Clear` 위젯도 centered-rect 헬퍼도 없다) 떠 있는 박스는 이 프로젝트 최초의 플로팅 UI가 되고 마우스 캡처가 기본 on이라 `hit_test.rs`에 새 히트 영역을 끼워야 한다. - **마우스 클릭 선택은 계획대로 범위 밖.** 키보드로 완결된다. ### 유지되는 원칙 -사용자가 입력한 텍스트는 다시 쓰지 않는다 — `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 -완성된 컴포넌트만 이어붙인다. `~/x`를 `/Users/me/x`로 바꿔 써넣지 않는다. 셸이 아니므로 -커맨드·`$VAR`·글롭·커맨드 치환은 없고 Enter는 항상 "이 경로 열기"다. +사용자가 입력한 텍스트는 다시 쓰지 않는다 — `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 완성된 컴포넌트만 이어붙인다. `~/x`를 `/Users/me/x`로 바꿔 써넣지 않는다. 셸이 아니므로 커맨드·`$VAR`·글롭·커맨드 치환은 없고 Enter는 항상 "이 경로 열기"다. ## git status XY 표기 ### 왜 git 표기를 그대로 쓰는가 -기존 표시는 collapse된 한 글자여서 "무엇이 바뀌었는가"는 보여도 "그 변경이 어디에 있는가"(staged / -unstaged / 둘 다)를 못 보여줬다. **새 멘탈 모델을 만드는 대신** 사용자가 `git status --short`에서 -이미 아는 `XY path` 관례를 그대로 가져왔다. git2가 깔끔히 표현하지 못하는 경우가 아니면 자체 -표기를 만들지 않는다는 것이 이 작업의 non-goal이었다. +기존 표시는 collapse된 한 글자여서 "무엇이 바뀌었는가"는 보여도 "그 변경이 어디에 있는가"(staged / unstaged / 둘 다)를 못 보여줬다. **새 멘탈 모델을 만드는 대신** 사용자가 `git status --short`에서 이미 아는 `XY path` 관례를 그대로 가져왔다. git2가 깔끔히 표현하지 못하는 경우가 아니면 자체 표기를 만들지 않는다는 것이 이 작업의 non-goal이었다. ### 접은 대안: `git status --short` 파싱 @@ -230,58 +127,33 @@ unstaged / 둘 다)를 못 보여줬다. **새 멘탈 모델을 만드는 대신 ### 결정: 두 칸이지만 enum은 하나 -`ChangedFile`이 `index`/`worktree` 두 컬럼을 갖되 **같은 `StatusKind` 하나**를 쓴다. 커밋 -drill-down은 `worktree = Unmodified`로 같은 타입을 재사용하므로 status 리스트와 커밋 리스트에서 -status의 의미가 하나다. 이름을 `ChangeStatus` → `StatusKind`로 바꾼 이유도 같다 — 이제 이 -엔티티는 "파일의 변경 종류"가 아니라 **단일 diff 컬럼의 상태**를 모델링하고, 그 컬럼은 -`Unmodified`일 수 있는데 옛 이름으로는 표현되지 않았다. +`ChangedFile`이 `index`/`worktree` 두 컬럼을 갖되 **같은 `StatusKind` 하나**를 쓴다. 커밋 drill-down은 `worktree = Unmodified`로 같은 타입을 재사용하므로 status 리스트와 커밋 리스트에서 status의 의미가 하나다. 이름을 `ChangeStatus` → `StatusKind`로 바꾼 이유도 같다 — 이제 이 엔티티는 "파일의 변경 종류"가 아니라 **단일 diff 컬럼의 상태**를 모델링하고, 그 컬럼은 `Unmodified`일 수 있는데 옛 이름으로는 표현되지 않았다. ### 결정: 색은 두 글자 한 덩어리에 최고 심각도 하나 -`unmerged > deleted > renamed > added > modified > typechanged > untracked` 순으로 더 심각한 -쪽 색을 두 글자 전체에 칠한다. 기존 단색 행 모양과 `status_color` 시그니처를 그대로 유지하기 -위해서다. +`unmerged > deleted > renamed > added > modified > typechanged > untracked` 순으로 더 심각한 쪽 색을 두 글자 전체에 칠한다. 기존 단색 행 모양과 `status_color` 시그니처를 그대로 유지하기 위해서다. ### 결정: 충돌 행은 첫 판에 `UU` 고정 -`AA`/`DD`/`AU`/`UD`/`DU`를 나중에 데이터 구조를 다시 만들지 않고 넣을 수 있도록, 구조화된 컬럼은 -유지한 채 렌더만 `UU`로 고정했다. **조용히 modified로 뭉개지 않는다**는 것이 요점이다. +`AA`/`DD`/`AU`/`UD`/`DU`를 나중에 데이터 구조를 다시 만들지 않고 넣을 수 있도록, 구조화된 컬럼은 유지한 채 렌더만 `UU`로 고정했다. **조용히 modified로 뭉개지 않는다**는 것이 요점이다. ### 결정: rename은 `path`와 표시를 분리 -`path`는 diff·파일 로드·hot-file 추적·선택 복원이 쓰는 유효(new-side) 경로로 남고, `old_path`는 -표시/검색 메타다. `display_path()`가 `old -> new`를 만들되 **비-rename에서는 `Cow::Borrowed`로 -할당 없이** 돌려준다 — 리스트 렌더는 매 프레임 돌고 그쪽이 hot case다. `impl Display`가 아니라 -`Cow`인 이유는 렌더러가 수평 스크롤 때문에 `char_offset(&str) -> &str`로 슬라이스하고 -`.chars().count()`로 재기 때문이다. 검색은 `search_lower`에 양쪽 경로를 함께 담아, 필터 로직을 -바꾸지 않고도 옛 경로/새 경로 어느 쪽으로도 찾힌다. +`path`는 diff·파일 로드·hot-file 추적·선택 복원이 쓰는 유효(new-side) 경로로 남고, `old_path`는 표시/검색 메타다. `display_path()`가 `old -> new`를 만들되 **비-rename에서는 `Cow::Borrowed`로 할당 없이** 돌려준다 — 리스트 렌더는 매 프레임 돌고 그쪽이 hot case다. `impl Display`가 아니라 `Cow`인 이유는 렌더러가 수평 스크롤 때문에 `char_offset(&str) -> &str`로 슬라이스하고 `.chars().count()`로 재기 때문이다. 검색은 `search_lower`에 양쪽 경로를 함께 담아, 필터 로직을 바꾸지 않고도 옛 경로/새 경로 어느 쪽으로도 찾힌다. ### 유지되는 제약 -- **정렬은 결정적이어야 한다.** 옛 `BTreeMap` "first-wins" collapse가 사라져도 안정 정렬은 - 남는다 — 새로고침마다 순서가 흔들리면 선택이 튄다. -- **typechange를 modified로 뭉개지 않는다.** `load_snapshot`(status 비트)과 - `load_commit_files`(`git2::Delta::Typechange`) 양쪽에서 `T`로 보존한다. -- 생산 코드에 대칭성만을 위한 헬퍼를 추가하지 않는다. 테스트에만 쓸 것은 `#[cfg(test)]` 아래 - 두고, 실제 호출처가 생길 때 만든다. +- **정렬은 결정적이어야 한다.** 옛 `BTreeMap` "first-wins" collapse가 사라져도 안정 정렬은 남는다 — 새로고침마다 순서가 흔들리면 선택이 튄다. +- **typechange를 modified로 뭉개지 않는다.** `load_snapshot`(status 비트)과 `load_commit_files`(`git2::Delta::Typechange`) 양쪽에서 `T`로 보존한다. +- 생산 코드에 대칭성만을 위한 헬퍼를 추가하지 않는다. 테스트에만 쓸 것은 `#[cfg(test)]` 아래 두고, 실제 호출처가 생길 때 만든다. ### 미룬 것 -같은 파일의 staged/unstaged diff를 따로 보여주는 것. 현재는 HEAD→workdir(인덱스 포함) 결합 -diff(`load_file_diff`)가 기본이고, 이 로더는 경로로만 키잉되어 status를 읽지 않으므로 모델 변경의 -영향을 받지 않는다. stage/unstage 액션이 들어올 때 다시 본다. +같은 파일의 staged/unstaged diff를 따로 보여주는 것. 현재는 HEAD→workdir(인덱스 포함) 결합 diff(`load_file_diff`)가 기본이고, 이 로더는 경로로만 키잉되어 status를 읽지 않으므로 모델 변경의 영향을 받지 않는다. stage/unstage 액션이 들어올 때 다시 본다. ## 그 밖 -- **플러그인은 dylib가 아니라 자식 프로세스 + NDJSON이다.** Rust에는 안정 ABI가 없어 - `libloading` 기반 dylib 플러그인은 컴파일러 버전이 맞아야만 동작한다. 현재 설계는 - [`architecture.md`](architecture.md)의 Plugin Host 절에 있다. -- **`git://`는 클론 URL 화이트리스트에서 뺐다.** 인증도 암호화도 없어 경로 위의 누구든 임의 - 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 슬롯을 - 재시작까지 쥔다. `https://`가 같은 익명 fetch를 두 문제 없이 대신한다. -- **호스트 터미널 커서 색을 OSC 12로 강제하지 않는다.** ratatui는 ANSI 코드로 렌더하고 호스트 - 팔레트가 그리는데, 별도 hex를 밀어 넣으면 어두운 ANSI green을 쓰는 터미널에서 커서만 밝은 - 라임으로 튄다. 관련 코드는 전부 제거했고 호스트 기본 커서 색을 그대로 쓴다. -- **`is_empty_head`의 문자열 매칭은 의도적이다.** 빈 repo에서 libgit2가 - `class=Reference + GenericError` 조합으로 응답해 ErrorCode 매칭만으로는 커버되지 않는다. - libgit2 내부 메시지는 로케일 독립이라 문자열 매칭이 portable하다. +- **플러그인은 dylib가 아니라 자식 프로세스 + NDJSON이다.** Rust에는 안정 ABI가 없어 `libloading` 기반 dylib 플러그인은 컴파일러 버전이 맞아야만 동작한다. 현재 설계는 [`architecture.md`](architecture.md)의 Plugin Host 절에 있다. +- **`git://`는 클론 URL 화이트리스트에서 뺐다.** 인증도 암호화도 없어 경로 위의 누구든 임의 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 슬롯을 재시작까지 쥔다. `https://`가 같은 익명 fetch를 두 문제 없이 대신한다. +- **호스트 터미널 커서 색을 OSC 12로 강제하지 않는다.** ratatui는 ANSI 코드로 렌더하고 호스트 팔레트가 그리는데, 별도 hex를 밀어 넣으면 어두운 ANSI green을 쓰는 터미널에서 커서만 밝은 라임으로 튄다. 관련 코드는 전부 제거했고 호스트 기본 커서 색을 그대로 쓴다. +- **`is_empty_head`의 문자열 매칭은 의도적이다.** 빈 repo에서 libgit2가 `class=Reference + GenericError` 조합으로 응답해 ErrorCode 매칭만으로는 커버되지 않는다. libgit2 내부 메시지는 로케일 독립이라 문자열 매칭이 portable하다. diff --git a/docs/getting-started.md b/docs/getting-started.md index 165e25ac..f901a4a8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,8 +2,7 @@ ## Install -Install straight from the repository (the built viewer bundle is committed, so -this needs no Node toolchain): +Install straight from the repository (the built viewer bundle is committed, so this needs no Node toolchain): ```bash cargo install --git https://github.com/code0xff/nightcrow --locked @@ -21,8 +20,7 @@ Once published to crates.io this will also work: cargo install nightcrow --locked ``` -Requires Rust 1.85+ (edition 2024) on macOS, Linux, or Windows. `--locked` -builds against the committed `Cargo.lock` for a reproducible install. +Requires Rust 1.85+ (edition 2024) on macOS, Linux, or Windows. `--locked` builds against the committed `Cargo.lock` for a reproducible install. ## Updating @@ -30,29 +28,18 @@ builds against the committed `Cargo.lock` for a reproducible install. nightcrow update ``` -This reinstalls from the upstream repository. `--path ` installs from a -local checkout instead, and `--git ` from a different repository. It runs -`cargo install` underneath, so it needs the same Rust toolchain the first -install did. +This reinstalls from the upstream repository. `--path ` installs from a local checkout instead, and `--git ` from a different repository. It runs `cargo install` underneath, so it needs the same Rust toolchain the first install did. -Prefer this over rerunning `cargo install` by hand, because on Windows the -plain install fails while a session is running: +Prefer this over rerunning `cargo install` by hand, because on Windows the plain install fails while a session is running: ``` error: failed to move `...\nightcrow.exe` to `...\nightcrow.exe` Caused by: Access is denied. (os error 5) ``` -Windows holds a lock on the file behind every running process, so the -installer cannot write over it — unlike macOS and Linux, where overwriting a -running binary is allowed and the plain `cargo install` works as-is. `update` -sidesteps the lock by renaming the installed binary out of the way first, -which Windows *does* permit, leaving the install path free. If the old binary -is still in use it is left beside the new one and removed the next time -nightcrow starts. +Windows holds a lock on the file behind every running process, so the installer cannot write over it — unlike macOS and Linux, where overwriting a running binary is allowed and the plain `cargo install` works as-is. `update` sidesteps the lock by renaming the installed binary out of the way first, which Windows *does* permit, leaving the install path free. If the old binary is still in use it is left beside the new one and removed the next time nightcrow starts. -A running session keeps the version it started with. Restart it to pick up the -new one: +A running session keeps the version it started with. Restart it to pick up the new one: ```bash nightcrow stop @@ -61,9 +48,7 @@ nightcrow attach ## Running a session -nightcrow runs as a **session**: one process holds the repositories and the -terminals, and you reach it from a terminal (`nightcrow attach`) or a browser. -Closing a client leaves the session running. +nightcrow runs as a **session**: one process holds the repositories and the terminals, and you reach it from a terminal (`nightcrow attach`) or a browser. Closing a client leaves the session running. ```bash # The usual way in: attach the TUI, starting a backgrounded session first if @@ -85,39 +70,19 @@ nightcrow attach nightcrow --exec "claude" --exec "codex" ``` -The session prints the address of its browser view (`http://127.0.0.1:8091/` -by default) and the socket an attaching terminal uses. Both show the same -repositories; open one with ` o` in the TUI or the folder picker in the -browser, and it appears in the other. There is no flag for opening a -repository — a session several clients share has one sensible place to do it, -and that is inside. +The session prints the address of its browser view (`http://127.0.0.1:8091/` by default) and the socket an attaching terminal uses. Both show the same repositories; open one with ` o` in the TUI or the folder picker in the browser, and it appears in the other. There is no flag for opening a repository — a session several clients share has one sensible place to do it, and that is inside. ## Detaching, disconnects, and shutdown -Leaving the TUI (` q`) detaches: the session, and everything running in -its terminals, keeps going. Stopping the session is stopping the process you -started it in — or `kill`ing it, if you used `-d`, in which case its output is -in `~/.nightcrow/daemon.out`. Under a service manager, start it *without* `-d`: -backgrounding is what the manager does itself. +Leaving the TUI (` q`) detaches: the session, and everything running in its terminals, keeps going. Stopping the session is stopping the process you started it in — or `kill`ing it, if you used `-d`, in which case its output is in `~/.nightcrow/daemon.out`. Under a service manager, start it *without* `-d`: backgrounding is what the manager does itself. -If the connection to the session ends under it — the session was stopped, or it -dropped a client that fell too far behind — the TUI leaves and says so, with a -non-zero status. What it had selected and scrolled is written back either way, so -reattaching returns to it. There is no automatic reconnect: reattach when the -session is up. +If the connection to the session ends under it — the session was stopped, or it dropped a client that fell too far behind — the TUI leaves and says so, with a non-zero status. What it had selected and scrolled is written back either way, so reattaching returns to it. There is no automatic reconnect: reattach when the session is up. ## Startup panes -Startup panes belong to a project, not to the process: each project you open -gets its own set. So `nightcrow --exec claude` with no repositories starts -`claude` in the first project opened, not before there is one to open it in. +Startup panes belong to a project, not to the process: each project you open gets its own set. So `nightcrow --exec claude` with no repositories starts `claude` in the first project opened, not before there is one to open it in. -`--exec` panes open after any `[[startup_command]]` panes from the -[config file](configuration.md#startup_command); the two sources share a -combined cap of 8 panes — the same count the ` 3`–`9`,`0` jump keys -address, so every startup pane is reachable by a direct key. (` 1`/`2` -map to the file list and diff viewer.) Panes opened later with ` t` are -not capped; any past the eighth are reached by focus cycling (`Shift+←/→`). +`--exec` panes open after any `[[startup_command]]` panes from the [config file](configuration.md#startup_command); the two sources share a combined cap of 8 panes — the same count the ` 3`–`9`,`0` jump keys address, so every startup pane is reachable by a direct key. (` 1`/`2` map to the file list and diff viewer.) Panes opened later with ` t` are not capped; any past the eighth are reached by focus cycling (`Shift+←/→`). ## Where to go next @@ -130,53 +95,26 @@ not capped; any past the eighth are reached by focus cycling (`Shift+←/→`). ### Prerequisites -Requires Rust 1.85+ (edition 2024). The viewer bundle is committed, so a -plain build needs no Node toolchain. Building the viewer from source needs -Node 22 — see `viewer-ui/`. +Requires Rust 1.85+ (edition 2024). The viewer bundle is committed, so a plain build needs no Node toolchain. Building the viewer from source needs Node 22 — see `viewer-ui/`. ### The four gates -`cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D -warnings`, and `cargo fmt --all --check` must all pass. The pre-push hook -(`git config core.hooksPath .githooks`) runs the same gates CI does, scoped to -what changed. - -Changing anything under `viewer-ui/src` adds two more, run before the Rust ones -because they fail faster: `npm --prefix viewer-ui test` covers the `src/lib` -helpers and the React hooks (a test that needs a DOM opts into happy-dom with a -first-line `// @vitest-environment happy-dom`; the rest run in plain node), -which no Rust test can reach, and `npm --prefix viewer-ui run build` -must leave `viewer-ui/dist` unchanged — the bundle is committed, so a source -edit without a rebuild ships a frontend that does not match its source. Both -need `node_modules`; the hook says so and moves on rather than failing when -Node is absent, since a plain build never needs it. - -It does stop, though, for a `node_modules` that is present but no longer the -one `package-lock.json` pins — run `npm --prefix viewer-ui ci` and push again. -Both gates above pass against whatever happens to be installed, so drift does -not fail here, it just makes the bundle they approve the wrong one; CI installs -from the lockfile and reports it as a stale bundle listing assets you never -touched. +`cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo fmt --all --check` must all pass. The pre-push hook (`git config core.hooksPath .githooks`) runs the same gates CI does, scoped to what changed. + +Changing anything under `viewer-ui/src` adds two more, run before the Rust ones because they fail faster: `npm --prefix viewer-ui test` covers the `src/lib` helpers and the React hooks (a test that needs a DOM opts into happy-dom with a first-line `// @vitest-environment happy-dom`; the rest run in plain node), which no Rust test can reach, and `npm --prefix viewer-ui run build` must leave `viewer-ui/dist` unchanged — the bundle is committed, so a source edit without a rebuild ships a frontend that does not match its source. Both need `node_modules`; the hook says so and moves on rather than failing when Node is absent, since a plain build never needs it. + +It does stop, though, for a `node_modules` that is present but no longer the one `package-lock.json` pins — run `npm --prefix viewer-ui ci` and push again. Both gates above pass against whatever happens to be installed, so drift does not fail here, it just makes the bundle they approve the wrong one; CI installs from the lockfile and reports it as a stale bundle listing assets you never touched. ### Verifying on the other platform -nightcrow targets macOS, Linux, and Windows, and CI runs the gates on all -three. If you are on one platform, the `std::os::unix` / `std::os::windows` -cfg gates mean the other platform's code does not compile locally — so a green -build on your machine is not proof that the others are green. +nightcrow targets macOS, Linux, and Windows, and CI runs the gates on all three. If you are on one platform, the `std::os::unix` / `std::os::windows` cfg gates mean the other platform's code does not compile locally — so a green build on your machine is not proof that the others are green. -Use the Docker gate to run all four gates on Linux from a Windows machine -(or vice versa, with the right image): +Use the Docker gate to run all four gates on Linux from a Windows machine (or vice versa, with the right image): ```bash docker compose run --rm unix-gate ``` -`compose.yml` runs `rust:latest` with named-volume caches for the cargo -registry and `target/`, so reruns finish in seconds rather than rebuilding -every dependency. CI runs the same gates on `ubuntu-latest`, but catching a -regression locally avoids the push-and-wait cycle. +`compose.yml` runs `rust:latest` with named-volume caches for the cargo registry and `target/`, so reruns finish in seconds rather than rebuilding every dependency. CI runs the same gates on `ubuntu-latest`, but catching a regression locally avoids the push-and-wait cycle. -**Known flaky test in Docker**: `a_reattaching_client_makes_an_alternate_screen_program_draw_again` -can fail in a container due to PTY timing under load. It passes on `dev` and -in CI (`ubuntu-latest`), so it is not a regression signal. +**Known flaky test in Docker**: `a_reattaching_client_makes_an_alternate_screen_program_draw_again` can fail in a container due to PTY timing under load. It passes on `dev` and in CI (`ubuntu-latest`), so it is not a regression signal. diff --git a/docs/keybindings.md b/docs/keybindings.md index a4378119..8435edd6 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -2,31 +2,13 @@ ## The leader key -nightcrow uses a tmux-style **leader (prefix)** key for its app commands. The -default leader is `Ctrl+F` (configurable via `[input] leader`). `Ctrl+F` is a -one-handed left-hand chord that avoids tmux's own `Ctrl+B` prefix (so nightcrow -stays usable inside a tmux session), terminal flow control (`Ctrl+Q`/`Ctrl+S`), -the shell signals (`Ctrl+C/D/Z`), and the Ctrl chords an inner Claude Code pane -reserves (`Ctrl+G` is its external editor, plus `Ctrl+O/R/S/T/L`) — its only -claimant is `Ctrl+F` as forward-char/page-forward, which most users reach via -the arrow keys instead. - -Press the leader, then a single follow-up key. Every other key — including Ctrl -chords like `Ctrl+W` and `Ctrl+L` — passes straight through to the focused -terminal, so a CLI running there (claude, codex, your shell) receives them -unchanged. This is why the leader exists: cockpit users live inside the terminal -panes and need their prompt-editing keys to reach the program, not nightcrow. - -The hint bar shows the active leader in caret notation at its left edge (e.g. -`^F: leader` for the default `Ctrl+F`), so the configured prefix is always -visible from the terminal pane. - -> **Migration from earlier versions:** the old bare-`Ctrl` app shortcuts moved -> behind the leader. `Ctrl+T/W/L/O/P/Q` are now ` t/w/l/o/p/q` and pass -> through to the terminal program instead; `Ctrl+F` is now the leader itself -> (` f` toggles fullscreen). The old `Ctrl+Q`-twice quit confirmation is -> gone; leave with ` q`, which now detaches rather than ending the -> session — stop the session itself with `nightcrow stop`. +nightcrow uses a tmux-style **leader (prefix)** key for its app commands. The default leader is `Ctrl+F` (configurable via `[input] leader`). `Ctrl+F` is a one-handed left-hand chord that avoids tmux's own `Ctrl+B` prefix (so nightcrow stays usable inside a tmux session), terminal flow control (`Ctrl+Q`/`Ctrl+S`), the shell signals (`Ctrl+C/D/Z`), and the Ctrl chords an inner Claude Code pane reserves (`Ctrl+G` is its external editor, plus `Ctrl+O/R/S/T/L`) — its only claimant is `Ctrl+F` as forward-char/page-forward, which most users reach via the arrow keys instead. + +Press the leader, then a single follow-up key. Every other key — including Ctrl chords like `Ctrl+W` and `Ctrl+L` — passes straight through to the focused terminal, so a CLI running there (claude, codex, your shell) receives them unchanged. This is why the leader exists: cockpit users live inside the terminal panes and need their prompt-editing keys to reach the program, not nightcrow. + +The hint bar shows the active leader in caret notation at its left edge (e.g. `^F: leader` for the default `Ctrl+F`), so the configured prefix is always visible from the terminal pane. + +> **Migration from earlier versions:** the old bare-`Ctrl` app shortcuts moved behind the leader. `Ctrl+T/W/L/O/P/Q` are now ` t/w/l/o/p/q` and pass through to the terminal program instead; `Ctrl+F` is now the leader itself (` f` toggles fullscreen). The old `Ctrl+Q`-twice quit confirmation is gone; leave with ` q`, which now detaches rather than ending the session — stop the session itself with `nightcrow stop`. ## Leader commands @@ -54,12 +36,9 @@ Press ``, then the key. | ` 1`…` 8` (terminal fullscreen) | Jump to terminal pane 1…8. With the viewer hidden the digit row addresses panes by natural numbering; `9`/`0` are unused. The only way back to the list/diff is ` f` to leave fullscreen | | `Esc` / `Ctrl+C` (while armed) | Cancel the prefix | -The prefix has no timeout: once armed it waits indefinitely for the follow-up -key. A key with no leader binding cancels the prefix and is dropped. +The prefix has no timeout: once armed it waits indefinitely for the follow-up key. A key with no leader binding cancels the prefix and is dropped. -` s` is the one two-step chord: it arms a swap mode (shown as `SWAP` in -the hint bar) that waits for a pane digit, then swaps the active pane with the -chosen one. A non-digit follow-up or `Esc` cancels swap mode without reordering. +` s` is the one two-step chord: it arms a swap mode (shown as `SWAP` in the hint bar) that waits for a pane digit, then swaps the active pane with the chosen one. A non-digit follow-up or `Esc` cancels swap mode without reordering. ## Global (no prefix) @@ -68,8 +47,7 @@ chosen one. A non-digit follow-up or `Esc` cancels swap mode without reordering. | `Shift+→` / `Shift+←` | Cycle focus: file list → diff viewer → terminal panes → … | | `F1`…`F10` | Switch to project tab 1…10 — see [Projects](projects.md). Unlike the pane digits, this mapping does not change with the layout: the same F-key reaches the same project in every view, fullscreen included | -A modified F-key (`Ctrl+F1`, `Shift+F5`, …) is not intercepted and passes -through to the terminal program. +A modified F-key (`Ctrl+F1`, `Shift+F5`, …) is not intercepted and passes through to the terminal program. ## File list / commit list (left panel) @@ -100,71 +78,32 @@ through to the terminal program. | `n` / `N` | Next / previous search match | | `Esc` | Clear search | -**Line numbers** are always shown in a pinned gutter. The unified view shows -both sides (old, new) — an added line leaves the old column blank, a removed -line leaves the new one blank. The split view numbers each half with the side it -shows, and the file view (`v`) numbers the file itself. The gutter stays in -place while `←`/`→` scroll the code. +**Line numbers** are always shown in a pinned gutter. The unified view shows both sides (old, new) — an added line leaves the old column blank, a removed line leaves the new one blank. The split view numbers each half with the side it shows, and the file view (`v`) numbers the file itself. The gutter stays in place while `←`/`→` scroll the code. ## Terminal panes (bottom) -Every visible pane renders at once as a split grid instead of switching -between tabs — 2 panes go side by side (or stacked if the terminal is -narrow), 4 form a 2x2 grid, up to 4 show normally and up to 8 in the -fullscreen grid. ` f` cycles the terminal through `off → grid → -zoom → off`: *grid* hides the top viewer and fills the screen with the -split grid, *zoom* fills the screen with just the active pane. +Every visible pane renders at once as a split grid instead of switching between tabs — 2 panes go side by side (or stacked if the terminal is narrow), 4 form a 2x2 grid, up to 4 show normally and up to 8 in the fullscreen grid. ` f` cycles the terminal through `off → grid → zoom → off`: *grid* hides the top viewer and fills the screen with the split grid, *zoom* fills the screen with just the active pane. -The active pane's cell is bordered in the accent color; jumping focus with -` 3`–`9`,`0` or `Shift+←/→` moves that border (and, while zoomed, the -pane on screen) without closing any other pane. With more panes than fit, the -tab bar shows a `+N` marker for the ones scrolled out of view — they keep -running in the background. Keyboard input, paste, and scroll still target only -the active pane. A single pane draws with no cell border. +The active pane's cell is bordered in the accent color; jumping focus with ` 3`–`9`,`0` or `Shift+←/→` moves that border (and, while zoomed, the pane on screen) without closing any other pane. With more panes than fit, the tab bar shows a `+N` marker for the ones scrolled out of view — they keep running in the background. Keyboard input, paste, and scroll still target only the active pane. A single pane draws with no cell border. | Key | Action | |-----|--------| | `Shift+↑` / `Shift+↓` | Scroll terminal output 3 lines | | `Shift+PgUp` / `Shift+PgDn` | Scroll terminal output one page | -While scrolled, the terminal border title shows -`[SCROLL — shift+pgdn: down | input: live]`. Keyboard input is still forwarded -to the running process; `Shift+PgDn` to scroll back to the bottom. +While scrolled, the terminal border title shows `[SCROLL — shift+pgdn: down | input: live]`. Keyboard input is still forwarded to the running process; `Shift+PgDn` to scroll back to the bottom. -The tab bar picks up OSC 0/2 window-title escape sequences, so programs like -`claude`, `vim`, `ssh`, or `cd`-aware shell prompts can rename their own tab. -Panes without an emitted title fall back to a default label. +The tab bar picks up OSC 0/2 window-title escape sequences, so programs like `claude`, `vim`, `ssh`, or `cd`-aware shell prompts can rename their own tab. Panes without an emitted title fall back to a default label. ## Mouse -nightcrow captures the mouse by default (`[mouse]` in the -[configuration](configuration.md#mouse)): - -- **Click a pane** to focus it, same as a jump key. The click is also forwarded - to programs that asked for mouse reports (Claude Code, `less --mouse`, …) — so - their clickable UI, like Claude Code's jump-to-bottom control, works. A plain - shell receives nothing. -- **Click the file list or diff viewer** to focus that panel, same as - ` 1`/`2`. -- **Click a project tab** in the top row to switch to it, same as its `F`-key. A - `+N` overflow marker jumps to the nearest project folded behind it. -- **Wheel** scrolls the pane under the pointer, routed exactly like the scroll - keys (wheel reports, arrow keys, or scrollback — whatever the program - expects). -- **Click a tab** in the terminal tab bar to jump to that pane; clicking a `+N` - hidden-pane marker reveals the nearest hidden pane on that side. -- **Click `o: open project`** on the empty screen — with no project open it is - the one action the hint bar offers, and it dispatches like its key. -- **Click a shortcut** in the bottom hint bar to run it — command hints like - `t: new pane`, `w: close pane`, or `f: fullscreen` dispatch exactly as if you - pressed the keys they name. Clickable hints render inverted (reverse video) - across their whole label so they stand out from informational hints; the - inversion disappears when `[mouse]` is disabled. Navigation hints and - `q: detach` are not clickable (detaching stays a deliberate two-key act). -- **Select text with a bypass modifier + drag.** While the mouse is captured, - the outer terminal performs its native selection and copy only when you hold - its bypass modifier while dragging. The modifier depends on the terminal: - **Shift** in xterm-family terminals (Alacritty, kitty, GNOME Terminal, Windows - Terminal), **Option (⌥)** in iTerm2, **Fn or Option** in macOS Terminal.app. - Set `enabled = false` under `[mouse]` to give the mouse back to the outer - terminal entirely — plain-drag selection returns, click forwarding stops. +nightcrow captures the mouse by default (`[mouse]` in the [configuration](configuration.md#mouse)): + +- **Click a pane** to focus it, same as a jump key. The click is also forwarded to programs that asked for mouse reports (Claude Code, `less --mouse`, …) — so their clickable UI, like Claude Code's jump-to-bottom control, works. A plain shell receives nothing. +- **Click the file list or diff viewer** to focus that panel, same as ` 1`/`2`. +- **Click a project tab** in the top row to switch to it, same as its `F`-key. A `+N` overflow marker jumps to the nearest project folded behind it. +- **Wheel** scrolls the pane under the pointer, routed exactly like the scroll keys (wheel reports, arrow keys, or scrollback — whatever the program expects). +- **Click a tab** in the terminal tab bar to jump to that pane; clicking a `+N` hidden-pane marker reveals the nearest hidden pane on that side. +- **Click `o: open project`** on the empty screen — with no project open it is the one action the hint bar offers, and it dispatches like its key. +- **Click a shortcut** in the bottom hint bar to run it — command hints like `t: new pane`, `w: close pane`, or `f: fullscreen` dispatch exactly as if you pressed the keys they name. Clickable hints render inverted (reverse video) across their whole label so they stand out from informational hints; the inversion disappears when `[mouse]` is disabled. Navigation hints and `q: detach` are not clickable (detaching stays a deliberate two-key act). +- **Select text with a bypass modifier + drag.** While the mouse is captured, the outer terminal performs its native selection and copy only when you hold its bypass modifier while dragging. The modifier depends on the terminal: **Shift** in xterm-family terminals (Alacritty, kitty, GNOME Terminal, Windows Terminal), **Option (⌥)** in iTerm2, **Fn or Option** in macOS Terminal.app. Set `enabled = false` under `[mouse]` to give the mouse back to the outer terminal entirely — plain-drag selection returns, click forwarding stops. diff --git a/docs/plugins.md b/docs/plugins.md index 40fe45ea..e3909fb9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,24 +1,12 @@ # Plugins -nightcrow itself knows nothing about the CLIs you run in its panes — an agent -and a person get the same PTY. Behaviour that *does* need to know a particular -tool lives in a plugin: a separate executable that nightcrow launches and talks -to over a pipe. +nightcrow itself knows nothing about the CLIs you run in its panes — an agent and a person get the same PTY. Behaviour that *does* need to know a particular tool lives in a plugin: a separate executable that nightcrow launches and talks to over a pipe. -Plugins are off unless you turn one on, and one only ever sees a pane you handed -it by name — or, if you also set `watch_on_signal`, a pane that something running -inside it spoke to the plugin from. A plugin is never given a list of your panes -either way. +Plugins are off unless you turn one on, and one only ever sees a pane you handed it by name — or, if you also set `watch_on_signal`, a pane that something running inside it spoke to the plugin from. A plugin is never given a list of your panes either way. ## The bundled plugin: `nightcrow-recovery` -Two jobs. It marks a project tab when a pane's agent finishes a turn — Claude -Code, via the `Stop` hook below. And when a watched pane's CLI hits its usage -limit, it waits for the reset time the provider reported and then re-opens that -exact session. It only waits — it does -not bypass, raise, or work around any provider limit, and it sends nothing while -a limit is in effect. Claude Code, Codex CLI, and OpenCode are supported; -OpenCode is only ever observed, never interrupted, because it retries on its own. +Two jobs. It marks a project tab when a pane's agent finishes a turn — Claude Code, via the `Stop` hook below. And when a watched pane's CLI hits its usage limit, it waits for the reset time the provider reported and then re-opens that exact session. It only waits — it does not bypass, raise, or work around any provider limit, and it sends nothing while a limit is in effect. Claude Code, Codex CLI, and OpenCode are supported; OpenCode is only ever observed, never interrupted, because it retries on its own. ```bash cargo build --release -p nightcrow-recovery @@ -27,14 +15,11 @@ nightcrow plugin list # what is installed, and how config refers to it nightcrow plugin remove recovery ``` -`install` prints the exact `[[plugin]]` block to paste, using whatever `--name` -you chose — that name is what a pane opts in with, so keep the two in step. +`install` prints the exact `[[plugin]]` block to paste, using whatever `--name` you chose — that name is what a pane opts in with, so keep the two in step. ## Enabling one -Installing only puts the binary in `~/.nightcrow/plugins`. It stays inert until -you edit `~/.nightcrow/config.toml` yourself — enabling something that can type -into a terminal should be a change you read before it takes effect: +Installing only puts the binary in `~/.nightcrow/plugins`. It stays inert until you edit `~/.nightcrow/config.toml` yourself — enabling something that can type into a terminal should be a change you read before it takes effect: ```toml [[plugin]] @@ -56,46 +41,25 @@ plugin = "recovery" # without this line, no plugin sees this pane unless ## Panes you opened by hand -That covers the panes you configured. For the pane you did not — you opened a -shell with ` t` and typed `claude` into it yourself — add -`watch_on_signal = true` to the `[[plugin]]` block. +That covers the panes you configured. For the pane you did not — you opened a shell with ` t` and typed `claude` into it yourself — add `watch_on_signal = true` to the `[[plugin]]` block. -nightcrow puts a random token in each pane's environment and nowhere else, so the -CLI's own hook can quote it back and the plugin can ask for "the pane this token -names"; a plain shell never speaks to a plugin, so your shells stay untouched. It -is off by default. Such a pane can be waited for and typed into but never -relaunched — nightcrow launched no command in it, so there is nothing to put back. +nightcrow puts a random token in each pane's environment and nowhere else, so the CLI's own hook can quote it back and the plugin can ask for "the pane this token names"; a plain shell never speaks to a plugin, so your shells stay untouched. It is off by default. Such a pane can be waited for and typed into but never relaunched — nightcrow launched no command in it, so there is nothing to put back. ## Claude Code hooks -For Claude Code, let the plugin install its hook and statusline entries so it can -read the exact session id and reset time instead of guessing from what is printed -on screen. With a reset time it waits exactly once; without one it falls back to -retrying on a backoff, which can give up. It merges into your existing -`~/.claude/settings.json` and backs it up first: +For Claude Code, let the plugin install its hook and statusline entries so it can read the exact session id and reset time instead of guessing from what is printed on screen. With a reset time it waits exactly once; without one it falls back to retrying on a backoff, which can give up. It merges into your existing `~/.claude/settings.json` and backs it up first: ```bash nightcrow-recovery install-hooks nightcrow-recovery uninstall-hooks # removes only what it added ``` -Claude Code's `statusLine` holds one command, so installing does replace yours — -but it is then run from the plugin's own statusline with the same input, and what -it prints is what you see. `uninstall-hooks` puts it back. +Claude Code's `statusLine` holds one command, so installing does replace yours — but it is then run from the plugin's own statusline with the same input, and what it prints is what you see. `uninstall-hooks` puts it back. -Installing also adds a `Stop` hook, which fires as every turn ends and marks that -pane's project tab (see [Projects](projects.md)). This exists because the marker -is otherwise inferred from what crosses the PTY — a terminal bell, or a burst of -title changes — and Claude Code reports a finished turn through desktop -notifications instead, which cross neither. The hook says so directly, so the -marker no longer depends on how long the turn was or how often the title moved. -It carries no payload: that the turn ended is the whole message. +Installing also adds a `Stop` hook, which fires as every turn ends and marks that pane's project tab (see [Projects](projects.md)). This exists because the marker is otherwise inferred from what crosses the PTY — a terminal bell, or a burst of title changes — and Claude Code reports a finished turn through desktop notifications instead, which cross neither. The hook says so directly, so the marker no longer depends on how long the turn was or how often the title moved. It carries no payload: that the turn ended is the whole message. ## Cancelling a pending recovery -A pane that is waiting shows its state and deadline on its tab. Cancel it with -` c` (see [Leader commands](keybindings.md#leader-commands)), or from the -web viewer; typing into the pane yourself also cancels it. +A pane that is waiting shows its state and deadline on its tab. Cancel it with ` c` (see [Leader commands](keybindings.md#leader-commands)), or from the web viewer; typing into the pane yourself also cancels it. -Design and trust boundary: -[Architecture → Plugin host](architecture/plugin-host.md). +Design and trust boundary: [Architecture → Plugin host](architecture/plugin-host.md). diff --git a/docs/projects.md b/docs/projects.md index b62584e5..fe0376c5 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -1,38 +1,19 @@ # Projects -One nightcrow process holds up to **10 repositories at once**, each in its own -tab across the top row. A project owns everything scoped to its repo — the git -views, the snapshot worker, and its own set of terminal panes — so switching -tabs swaps the whole screen, not just the diff. A pane running a build in one -project keeps running while you work in another. +One nightcrow process holds up to **10 repositories at once**, each in its own tab across the top row. A project owns everything scoped to its repo — the git views, the snapshot worker, and its own set of terminal panes — so switching tabs swaps the whole screen, not just the diff. A pane running a build in one project keeps running while you work in another. ``` F1 nightcrow F2 api-server +3 ← project tabs (active one accented) ┌ ^F 1 Files ──────┐┌ ^F 2 src/main.rs ────┐ ``` -- `^F o` opens a repo in a tab, `^F x` closes the active one, and `F1`…`F10` - switch between them. There is no "change this tab's repo": closing and - opening is the same thing, and it tears the old project down properly - instead of leaving its shells behind in the previous directory. -- Opening a repo another tab already holds focuses that tab instead of running - two copies against one worktree. -- When the tabs outgrow the row, it scrolls around the active tab and folds the - rest behind `+N` markers; clicking a marker jumps to the nearest project - behind it. -- A blinking `•` marks a background project whose terminal needs attention. - Opening that project acknowledges everything seen so far; later activity can - light it again. A terminal bell raises it, as does a pane exiting or a burst - of title changes settling. A tool that reports finishing some other way can - say so directly through a plugin — see [Plugins](plugins.md#claude-code-hooks). +- `^F o` opens a repo in a tab, `^F x` closes the active one, and `F1`…`F10` switch between them. There is no "change this tab's repo": closing and opening is the same thing, and it tears the old project down properly instead of leaving its shells behind in the previous directory. +- Opening a repo another tab already holds focuses that tab instead of running two copies against one worktree. +- When the tabs outgrow the row, it scrolls around the active tab and folds the rest behind `+N` markers; clicking a marker jumps to the nearest project behind it. +- A blinking `•` marks a background project whose terminal needs attention. Opening that project acknowledges everything seen so far; later activity can light it again. A terminal bell raises it, as does a pane exiting or a burst of title changes settling. A tool that reports finishing some other way can say so directly through a plugin — see [Plugins](plugins.md#claude-code-hooks). -**No project open** is a normal state, not an error — it is how a fresh -session starts, and where closing the last tab returns you. The screen -keeps its chrome and offers the only two things that apply: `^F o` to open a -repo, `^F q` to detach. +**No project open** is a normal state, not an error — it is how a fresh session starts, and where closing the last tab returns you. The screen keeps its chrome and offers the only two things that apply: `^F o` to open a repo, `^F q` to detach. -Each project keeps its own session file (see -[Session state](session-state.md)), so tabs restore independently. +Each project keeps its own session file (see [Session state](session-state.md)), so tabs restore independently. -Typing a path into the repo dialog, completing it with `Tab`, and browsing for -one with `↓` are covered in [Views → the repo dialog](views.md#the-repo-dialog). +Typing a path into the repo dialog, completing it with `Tab`, and browsing for one with `↓` are covered in [Views → the repo dialog](views.md#the-repo-dialog). diff --git a/docs/session-state.md b/docs/session-state.md index a5c3d5dd..97c84896 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -2,52 +2,24 @@ ## Recent-activity focus indicator -Files modified within the last `hot_window_secs` seconds — whether by an agent -in a terminal pane, your editor, or a build/format script — are rendered in the -accent color (bold for the first 5 seconds, normal until the window expires). +Files modified within the last `hot_window_secs` seconds — whether by an agent in a terminal pane, your editor, or a build/format script — are rendered in the accent color (bold for the first 5 seconds, normal until the window expires). -When the file list is in focus and you have not navigated in the last 2 seconds, -the selection auto-follows to the freshest hot file so the diff updates as files -change. Manual navigation (`j` / `k` / arrows / PgUp / PgDn) immediately -suppresses auto-follow until you go idle again. +When the file list is in focus and you have not navigated in the last 2 seconds, the selection auto-follows to the freshest hot file so the diff updates as files change. Manual navigation (`j` / `k` / arrows / PgUp / PgDn) immediately suppresses auto-follow until you go idle again. -Configurable under -[`[agent_indicator]`](configuration.md#agent_indicator). +Configurable under [`[agent_indicator]`](configuration.md#agent_indicator). ## What persists -nightcrow saves the current state on exit and restores it on the next launch — -focus position, selected file, scroll offset, active terminal pane, view mode -(status / commit log / tree), fullscreen states, commit-log drill-down position, -and tree expansion and selection. - -The accent is not in that list. It belongs to the session rather than to one -repo's view state, so it lives in `~/.nightcrow/viewer.json` alongside the -viewer's other shared preferences and is not restored per repo. - -The browser keeps its own half of this. Which panel each project is maximized in -is remembered per project in `viewer.json`, so a refresh comes back to the layout -you left — the browser's counterpart to the fullscreen states above, kept apart -from them because maximizing on a 40-row terminal and in a browser window are not -the same answer. It is held for 50 projects, like the TUI's — the 50 whose -arrangement was set most recently, so maximizing a fifty-first is what drops the -oldest, not merely opening one. - -Everything else lands in one file, `~/.nightcrow/workspace.json` — which repos -were open, which tab was in front, and each repo's view state. Nothing is written -inside your repositories: no single repo owns the fact that others were open -beside it, and nightcrow shouldn't create directories in a project it is only -reading. - -A bare `nightcrow` reopens those tabs and lands on the one that was in front, -with each project's selection and scroll where you left them. Repos that have -moved or been deleted since are skipped, with a notice saying how many. View -state is kept for the 50 most recently used repos. +nightcrow saves the current state on exit and restores it on the next launch — focus position, selected file, scroll offset, active terminal pane, view mode (status / commit log / tree), fullscreen states, commit-log drill-down position, and tree expansion and selection. + +The accent is not in that list. It belongs to the session rather than to one repo's view state, so it lives in `~/.nightcrow/viewer.json` alongside the viewer's other shared preferences and is not restored per repo. + +The browser keeps its own half of this. Which panel each project is maximized in is remembered per project in `viewer.json`, so a refresh comes back to the layout you left — the browser's counterpart to the fullscreen states above, kept apart from them because maximizing on a 40-row terminal and in a browser window are not the same answer. It is held for 50 projects, like the TUI's — the 50 whose arrangement was set most recently, so maximizing a fifty-first is what drops the oldest, not merely opening one. + +Everything else lands in one file, `~/.nightcrow/workspace.json` — which repos were open, which tab was in front, and each repo's view state. Nothing is written inside your repositories: no single repo owns the fact that others were open beside it, and nightcrow shouldn't create directories in a project it is only reading. + +A bare `nightcrow` reopens those tabs and lands on the one that was in front, with each project's selection and scroll where you left them. Repos that have moved or been deleted since are skipped, with a notice saying how many. View state is kept for the 50 most recently used repos. ## Who writes what -The two halves have two owners. The session writes which repositories are open -and which tab is in front; an attached client writes what it had selected and -scrolled, and never the tab list — detaching must not roll the session back to -one client's view of it. To start empty, close every tab before stopping the -session. +The two halves have two owners. The session writes which repositories are open and which tab is in front; an attached client writes what it had selected and scrolled, and never the tab list — detaching must not roll the session back to one client's view of it. To start empty, close every tab before stopping the session. diff --git a/docs/views.md b/docs/views.md index fbe54847..6091ebcc 100644 --- a/docs/views.md +++ b/docs/views.md @@ -2,13 +2,9 @@ ## Status view -The default. Lists changed files on the left, syntax-highlighted diff on the -right. +The default. Lists changed files on the left, syntax-highlighted diff on the right. -Each row begins with a two-character `XY` status code, following Git's short -status notation (nightcrow reads status through git2 internally, not by parsing -`git status --short`). `X` is the staged (index) state and `Y` is the unstaged -(working-tree) state, so a file can show both at once: +Each row begins with a two-character `XY` status code, following Git's short status notation (nightcrow reads status through git2 internally, not by parsing `git status --short`). `X` is the staged (index) state and `Y` is the unstaged (working-tree) state, so a file can show both at once: | Code | Meaning | | --- | --- | @@ -26,90 +22,43 @@ The diff for a selected file shows the combined working-tree-with-index changes. ## Commit log view (` l`) -A tig-like commit list on the left, full commit diff on the right. Commits -ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill -into its individual files; `Esc` to go back. +A tig-like commit list on the left, full commit diff on the right. Commits ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill into its individual files; `Esc` to go back. -The list auto-refreshes when the workdir HEAD changes (commits made in the -terminal pane, amends, force-pushes, branch switches). History loads one page at -a time — initial entry fetches `commit_log_page_size` commits and additional -pages stream in on a background thread as the selection approaches the loaded -tail, so deep histories stay responsive. Toggling while a terminal or diff pane -is zoomed exits the zoom and focuses the list, so the view switch is always -visible. +The list auto-refreshes when the workdir HEAD changes (commits made in the terminal pane, amends, force-pushes, branch switches). History loads one page at a time — initial entry fetches `commit_log_page_size` commits and additional pages stream in on a background thread as the selection approaches the loaded tail, so deep histories stay responsive. Toggling while a terminal or diff pane is zoomed exits the zoom and focuses the list, so the view switch is always visible. ## Tree view (` b`) -A read-only directory tree of the whole working tree on the left, with the -selected file's raw contents on the right. Unlike the status view (which lists -only changed files), the tree lets you browse and read *any* file next to the -diff without leaving nightcrow. - -- `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a - time), `←` collapses it or steps up to the parent, and selecting a file - previews it. -- `Enter` on a file row opens it in the preview pane and zooms that pane - fullscreen (`Enter` again, or ` f`, exits the zoom); on a directory - row it does nothing. -- `/` while the tree is focused runs a recursive filename search across the - whole tree — type to filter, `Enter` reveals the selected match in place - (expanding its ancestor directories), `Esc` cancels. -- Focus the file preview with ` 2`, then press `/` to search within the - file contents — `n`/`N` jump to the next/previous match, `Esc` clears the - search. - -`.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by -default — toggle with `[tree] respect_gitignore`. Expanded directories are -watched for filesystem changes, so files and folders created, moved, or deleted -by another process (an editor, `git`, an LLM CLI) appear without leaving the -view; set `[tree] live_watch = false` to refresh only on entry instead. See -[Configuration → `[tree]`](configuration.md#tree). - -The tree never writes, renames, or deletes anything. Expansion state and the -selected path persist across sessions. +A read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. + +- `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. +- `Enter` on a file row opens it in the preview pane and zooms that pane fullscreen (`Enter` again, or ` f`, exits the zoom); on a directory row it does nothing. +- `/` while the tree is focused runs a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. +- Focus the file preview with ` 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. + +`.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. See [Configuration → `[tree]`](configuration.md#tree). + +The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions. ## Notice row -A one-row strip just above the hint bar shows the repo path (home-relative, e.g. -`~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when -the branch tracks an upstream. A path or a branch too long for the row is cut -with `…` — the counts and the recovery chip after them keep their room, so a -long name shortens itself rather than pushing them off the end. - -When something fails — a git snapshot, a diff load, a terminal pane, or a repo -path you typed that doesn't exist — the message takes over this row in red until -the problem is resolved or you act on the app again. - -While the repo dialog is open, its input takes this row in the header's place — -you are deciding which repo the header will name next — and the messages move -down to the hint row: a rejected path appears directly below the input you're -correcting, and the dialog's completion candidates show there too (dimmed, and -a notice outranks them), so a list too long for one line ends in `+N more`. +A one-row strip just above the hint bar shows the repo path (home-relative, e.g. `~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when the branch tracks an upstream. A path or a branch too long for the row is cut with `…` — the counts and the recovery chip after them keep their room, so a long name shortens itself rather than pushing them off the end. + +When something fails — a git snapshot, a diff load, a terminal pane, or a repo path you typed that doesn't exist — the message takes over this row in red until the problem is resolved or you act on the app again. + +While the repo dialog is open, its input takes this row in the header's place — you are deciding which repo the header will name next — and the messages move down to the hint row: a rejected path appears directly below the input you're correcting, and the dialog's completion candidates show there too (dimmed, and a notice outranks them), so a list too long for one line ends in `+N more`. When neither is up, the hint row spells out the dialog's keys. ## The repo dialog ### Path completion -`Tab` completes the directory you're typing, so you don't have to know the path -by heart. One press extends as far as the names allow; when there's nothing left -to extend it lists what's there instead. On a trailing `/` the first press shows -that directory's contents, and a unique match gains a trailing `/` so you can -keep pressing `Tab` to descend. +`Tab` completes the directory you're typing, so you don't have to know the path by heart. One press extends as far as the names allow; when there's nothing left to extend it lists what's there instead. On a trailing `/` the first press shows that directory's contents, and a unique match gains a trailing `/` so you can keep pressing `Tab` to descend. -Only directories are offered (a file can't be a repo), dotted directories stay -hidden until you type a leading `.`, and a name that differs only in case is -matched and corrected for you. The dialog is a path field, not a shell — `~`, -`..` and paths relative to your working directory all work, but `cd`, `$VAR`, -and globs don't, and `Enter` always means "open this path". +Only directories are offered (a file can't be a repo), dotted directories stay hidden until you type a leading `.`, and a name that differs only in case is matched and corrected for you. The dialog is a path field, not a shell — `~`, `..` and paths relative to your working directory all work, but `cd`, `$VAR`, and globs don't, and `Enter` always means "open this path". ### Browsing for a repo -When you don't know the path, press `↓` in the repo dialog to browse instead of -typing. (A second `Tab`, once the candidate list is up, opens the same browser: -at that point the flat list has told you all it can.) The browser fills the body -of the screen, rooted at whatever directory the field currently names, and the -field stays visible below it with the keys spelled out. +When you don't know the path, press `↓` in the repo dialog to browse instead of typing. (A second `Tab`, once the candidate list is up, opens the same browser: at that point the flat list has told you all it can.) The browser fills the body of the screen, rooted at whatever directory the field currently names, and the field stays visible below it with the keys spelled out. | Key | Action | |-----|--------| @@ -119,9 +68,4 @@ field stays visible below it with the keys spelled out. | `Enter` | Take the selected path into the field and return to it — this does **not** open the repo. Press `Enter` again in the field for that, or keep refining the path with `Tab` first | | `Esc` | Leave the browser, keeping the text it started from. A second `Esc` cancels the dialog | -Directories only, hidden ones excluded, and nothing is ever written. Note that -`Enter` means *select* here but *open* in the field — the browser's job is to -fill the field, so `→` alone expands — matching the file-tree view, where -`Enter` opens a file rather than expanding. Paths keep your own notation: -browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. -Mouse selection isn't supported; the browser is keyboard-only. +Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands — matching the file-tree view, where `Enter` opens a file rather than expanding. Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only. diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 3c9c7c82..5e099968 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -1,257 +1,80 @@ # Web viewer -A browser surface that renders the same git data as a native web page — -selectable text, real scrolling, clickable paths, and a layout that adapts to a -phone. It also serves the session's terminals, the same panes an attached TUI -sees. +A browser surface that renders the same git data as a native web page — selectable text, real scrolling, clickable paths, and a layout that adapts to a phone. It also serves the session's terminals, the same panes an attached TUI sees. It is always on — it is one of the session's two faces, not an add-on. ## Projects in the browser -The served repositories appear as project tabs in the header — `+ open` browses -the server machine's folders to add one, `×` closes it, and dragging a tab -reorders them. - -The same dialog **clones a git URL** into the folder it is showing: paste -`https://…` or `git@host:path`, and the repository opens as a tab when the clone -finishes. Cloning runs `git` on the server, so it uses that machine's credentials -— an SSH agent, a credential helper — and a private remote works exactly as it -would in a shell there. Local paths and git's `ext::` transport are refused. A -clone keeps running whether or not you stay to watch it: closing the dialog -leaves `Cloning…` in the header, and a page you reload — or a phone that dropped -the tab mid-transfer — picks the same clone back up and still opens the -repository when it lands. - -Each project has its own `status`, `log`, and `tree` tabs on the left plus a -terminal panel below. The order is kept on the server, so every device shows the -same arrangement, and it survives a restart (alongside the TUI it lasts the -session). On a narrow window the tab row folds into a dropdown showing the -current project. +The served repositories appear as project tabs in the header — `+ open` browses the server machine's folders to add one, `×` closes it, and dragging a tab reorders them. + +The same dialog **clones a git URL** into the folder it is showing: paste `https://…` or `git@host:path`, and the repository opens as a tab when the clone finishes. Cloning runs `git` on the server, so it uses that machine's credentials — an SSH agent, a credential helper — and a private remote works exactly as it would in a shell there. Local paths and git's `ext::` transport are refused. A clone keeps running whether or not you stay to watch it: closing the dialog leaves `Cloning…` in the header, and a page you reload — or a phone that dropped the tab mid-transfer — picks the same clone back up and still opens the repository when it lands. + +Each project has its own `status`, `log`, and `tree` tabs on the left plus a terminal panel below. The order is kept on the server, so every device shows the same arrangement, and it survives a restart (alongside the TUI it lasts the session). On a narrow window the tab row folds into a dropdown showing the current project. ## Views -**A project opens onto what it was last showing.** The tab you were in, the file -you had open, and the directories the tree had expanded come back when you open -that project again — on the next visit, after a reload, and on whatever device -you pick up next, since the server keeps it. The TUI has done this since it had a -session file; this is the same idea in the browser, kept in the viewer's own file -rather than the TUI's, so the two do not overwrite each other. A file that has -gone since you left simply does not open: the project comes back to its list, not -to an error, and keeps asking for it next time — the server answers a deleted -file and one it could not read the same way, so forgetting on the first sign of -trouble would throw away a perfectly good memory. On a phone, restoring does not move you: whichever of the three -views you were on is the one you stay on, with the file waiting behind it. - -In the `log` tab, selecting a commit opens its changed-file list alongside the -complete commit diff. Select a file to view only that file's change; use `< log` -to return or `all changes` to restore the complete commit diff. - -History loads a page at a time, as the TUI's does — scrolling toward the end of -the list fetches the next page, so deep histories stay reachable without loading -them up front. The filter narrows the commits already loaded rather than -searching the server, so paging pauses while a query is up — the list says how -many are loaded, and clearing the filter resumes loading. The list follows HEAD -the way the TUI's does: a commit made in the terminal panel below appears at the -top on its own, without disturbing the pages you have scrolled through. A rewrite -of the history you were reading — a rebase, an amend — replaces the list with the -new history instead, and closes a commit drill-down whose commit it swept away. - -With a diff showing, the content pane has a toggle (top-right) that switches -between the inline unified diff and a side-by-side split view, mirroring the -TUI's `s`. The choice lasts the page, the same lifetime the TUI gives it; on a -narrow window the two sides stack — removed above added — rather than sitting -side by side, since neither column would have the width to read. - -Beside it, a **whole file** toggle swaps the diff for the file it belongs to, -opened at the change that was on screen — the browser's half of the TUI's `v`. -It shows the file as the commit left it when you reached the diff from the log, -and the working copy when you reached it from the status list, so what you read -is what the diff was describing. Press it again for the diff. It appears only -where there is a second face to show: a whole-commit diff spans several files, -so "which one" has no answer, and a file opened from the tree has no diff behind -it. The TUI draws the same two lines. - -**Line numbers** ride in a pinned gutter as they do in the TUI: the unified view -shows both sides (old, new), leaving a column blank where the line does not exist -on that side; each split half shows the side it renders; and a file opened from -the tree is numbered by its own lines. The gutter stays put while the code -scrolls sideways, and the numbers stay out of anything you copy. - -The `status` list highlights recently touched files the same way the TUI does: -accent-coloured and bold for the first 5 seconds after a file's mtime, accent -until `agent_indicator.hot_window_secs` expires, then plain. The window (and -whether the highlight runs at all) comes from the server's `[agent_indicator]` -settings, so both surfaces fade on the same schedule. Ageing is measured against -the browser's clock, so a device whose time is badly off will fade early or late. - -Markdown files (`.md`, `.markdown`) opened from the tree render as formatted -documents by default, with fenced code syntax-highlighted. HTML files (`.html`, -`.htm`) render too, inside a sandboxed frame that allows the document's own -inline scripts and nothing else — so an interactive single-file page works (a -slide deck's keyboard navigation, a chart that draws itself), while the frame -stays cut off from the session: it runs as no origin, its requests carry no -login, and nothing loads from or connects to another host. A page that carries -its scripts and styling inline and embeds images as `data:` URIs runs in full; -one that links a stylesheet, images, or scripts as separate files (or from a -CDN) shows without them. This previews a self-contained page rather than a -site. A toggle (top-right of the pane) switches either back to the raw -highlighted source. Click the frame first if keys seem to go nowhere — the -keyboard follows focus. +**A project opens onto what it was last showing.** The tab you were in, the file you had open, and the directories the tree had expanded come back when you open that project again — on the next visit, after a reload, and on whatever device you pick up next, since the server keeps it. The TUI has done this since it had a session file; this is the same idea in the browser, kept in the viewer's own file rather than the TUI's, so the two do not overwrite each other. A file that has gone since you left simply does not open: the project comes back to its list, not to an error, and keeps asking for it next time — the server answers a deleted file and one it could not read the same way, so forgetting on the first sign of trouble would throw away a perfectly good memory. On a phone, restoring does not move you: whichever of the three views you were on is the one you stay on, with the file waiting behind it. + +In the `log` tab, selecting a commit opens its changed-file list alongside the complete commit diff. Select a file to view only that file's change; use `< log` to return or `all changes` to restore the complete commit diff. + +History loads a page at a time, as the TUI's does — scrolling toward the end of the list fetches the next page, so deep histories stay reachable without loading them up front. The filter narrows the commits already loaded rather than searching the server, so paging pauses while a query is up — the list says how many are loaded, and clearing the filter resumes loading. The list follows HEAD the way the TUI's does: a commit made in the terminal panel below appears at the top on its own, without disturbing the pages you have scrolled through. A rewrite of the history you were reading — a rebase, an amend — replaces the list with the new history instead, and closes a commit drill-down whose commit it swept away. + +With a diff showing, the content pane has a toggle (top-right) that switches between the inline unified diff and a side-by-side split view, mirroring the TUI's `s`. The choice lasts the page, the same lifetime the TUI gives it; on a narrow window the two sides stack — removed above added — rather than sitting side by side, since neither column would have the width to read. + +Beside it, a **whole file** toggle swaps the diff for the file it belongs to, opened at the change that was on screen — the browser's half of the TUI's `v`. It shows the file as the commit left it when you reached the diff from the log, and the working copy when you reached it from the status list, so what you read is what the diff was describing. Press it again for the diff. It appears only where there is a second face to show: a whole-commit diff spans several files, so "which one" has no answer, and a file opened from the tree has no diff behind it. The TUI draws the same two lines. + +**Line numbers** ride in a pinned gutter as they do in the TUI: the unified view shows both sides (old, new), leaving a column blank where the line does not exist on that side; each split half shows the side it renders; and a file opened from the tree is numbered by its own lines. The gutter stays put while the code scrolls sideways, and the numbers stay out of anything you copy. + +The `status` list highlights recently touched files the same way the TUI does: accent-coloured and bold for the first 5 seconds after a file's mtime, accent until `agent_indicator.hot_window_secs` expires, then plain. The window (and whether the highlight runs at all) comes from the server's `[agent_indicator]` settings, so both surfaces fade on the same schedule. Ageing is measured against the browser's clock, so a device whose time is badly off will fade early or late. + +Markdown files (`.md`, `.markdown`) opened from the tree render as formatted documents by default, with fenced code syntax-highlighted. HTML files (`.html`, `.htm`) render too, inside a sandboxed frame that allows the document's own inline scripts and nothing else — so an interactive single-file page works (a slide deck's keyboard navigation, a chart that draws itself), while the frame stays cut off from the session: it runs as no origin, its requests carry no login, and nothing loads from or connects to another host. A page that carries its scripts and styling inline and embeds images as `data:` URIs runs in full; one that links a stylesheet, images, or scripts as separate files (or from a CDN) shows without them. This previews a self-contained page rather than a site. A toggle (top-right of the pane) switches either back to the raw highlighted source. Click the frame first if keys seem to go nowhere — the keyboard follows focus. ## Layout -The swatch in the header cycles the accent colour through the same five presets -as the TUI's ` p` (yellow → cyan → green → magenta → blue) — and it is -the same colour, not a parallel one. The choice is stored on the server -(`~/.nightcrow/viewer.json`), so every device that opens the viewer and every -attached TUI shows it, and a change made anywhere reaches the browsers within a -few seconds and attached terminals immediately. `[theme] name` sets the colour a -session starts with, before anyone has picked one. - -Drag the divider between the sidebar and the content pane to resize the sidebar, -or double-click it to reset the default width. The width is stored on the server -the same way as the accent, so every device opens at the same split; it is -bounded so the content pane always keeps at least half the window. - -The border between the upper panel and the terminal panel is a divider too: drag -it to give the terminal more or less of the window, double-click to go back to -the default 55/45. It is stored on the server like the sidebar width, so every -browser opens at the same split, and bounded so neither panel shrinks to a sliver -— for "all the way" use the maximize buttons on either panel. Unlike the accent, -this one is **not** shared with an attached TUI: the TUI keeps its own -`[layout] upper_pct`, because the same percentage means a different number of -rows on a terminal than in a browser window, and the terminals' actual size is -already decided by whichever client owns the sizing. +The swatch in the header cycles the accent colour through the same five presets as the TUI's ` p` (yellow → cyan → green → magenta → blue) — and it is the same colour, not a parallel one. The choice is stored on the server (`~/.nightcrow/viewer.json`), so every device that opens the viewer and every attached TUI shows it, and a change made anywhere reaches the browsers within a few seconds and attached terminals immediately. `[theme] name` sets the colour a session starts with, before anyone has picked one. + +Drag the divider between the sidebar and the content pane to resize the sidebar, or double-click it to reset the default width. The width is stored on the server the same way as the accent, so every device opens at the same split; it is bounded so the content pane always keeps at least half the window. + +The border between the upper panel and the terminal panel is a divider too: drag it to give the terminal more or less of the window, double-click to go back to the default 55/45. It is stored on the server like the sidebar width, so every browser opens at the same split, and bounded so neither panel shrinks to a sliver — for "all the way" use the maximize buttons on either panel. Unlike the accent, this one is **not** shared with an attached TUI: the TUI keeps its own `[layout] upper_pct`, because the same percentage means a different number of rows on a terminal than in a browser window, and the terminals' actual size is already decided by whichever client owns the sizing. ## Terminals -Each terminal pane's toolbar has a **fit to this screen** button, the browser's -half of the TUI's ` z`. It is offered only while another screen holds the -sizing, because a PTY has one size for the whole session: the panes are fitted to -whichever viewer opened most recently, and everyone else renders that grid until -someone asks for it. Switching projects does not move it, and neither does a -dropped connection coming back — a tab is one screen however many sockets it -opens. Reloading the page counts as opening it, so it takes the sizing again, as -a new tab would. - -Nobody holding the sizing is a state for a session with nobody in it. If every -screen goes and one comes back — a phone that slept long enough for its socket -to die — it takes the sizing rather than returning as a spectator, because there -is no other screen to take it from. - -The panel draws its panes either side by side, as the TUI does, or one at a time -behind a tab strip. The button beside **+** switches between the two, and a -narrow screen starts on tabs — a split grid gives each pane fewer columns than a -command line needs. Once you pick, that choice sticks on that device, rotation -included; it is stored in the browser rather than on the server, because what a -phone should do with four panes is not what the desktop beside it should do. - -Tabs change nothing about the session: **+** still opens a terminal that every -client sees, the tabs sit in pane order, and a tab you are not looking at is a -running program whose output keeps arriving. Every pane is also held at the -panel's full size while tabbed, so switching tabs costs no resize — which is the -same reason a tabbed browser and an attached TUI cannot both be right about how -wide a pane is. Give the sizing to whichever screen you are working on with the -button above, or leave the TUI holding it and read the panes at its width. - -A tabbed panel shows no **zoom** button — it already shows one pane — and a zoom -another client set does not move the keyboard here. - -Drag a terminal pane by its header, or by its tab, onto another to reorder them; -it works with touch as well as a mouse. The order is kept on the server, so a -refresh, a reconnect, or another device opening the same repository all show the -same arrangement. (It is not written to disk — a server restart clears the -terminals themselves, so there is nothing to persist.) - -The **zoom** button on a pane's toolbar fills the panel with that one terminal, -and the keyboard follows it. Like the order, and for the same reason, it is kept -on the server: a refresh comes back to the pane you had zoomed, and another -device showing the same project follows. Opening a terminal ends the zoom — the -new one would be behind it otherwise — and so does closing the zoomed pane. It -is not written to disk either, and cannot be: a zoom names a pane, and -restarting the session ends the panes. An attached TUI keeps its own -` f` zoom rather than following this one — the panes are shared, but -what fills a screen is that screen's. - -To copy from a pane, select with the mouse and press the copy key your browser -already uses. A plain drag selects only while the pane's program is not reading -the mouse itself; most full-screen programs do read it, and then a drag is -theirs — that is how clicking a menu in one of them works at all. Hold a -modifier to take the drag back for a selection: **Option** on a Mac, **Shift** -everywhere else. There is nothing to copy until something is selected, so -without the modifier the copy key looks broken rather than empty. - -A program running in a pane can also copy on its own — Claude Code's `/copy`, -vim's OSC 52 clipboard, tmux's `copy-pipe`. That copy reaches *this* page, not -the machine hosting the session, which is what makes it worth having: the -`pbcopy` such a program also runs writes to a clipboard nobody at this end can -reach. Most of the time it simply happens, including over plain `http://`. - -When the browser refuses to fill the clipboard without being asked — Safari -wants a press for it, and any browser may — a notice appears with a **Copy** -button instead, and pressing it is the press it wanted. It stays up until the -text is across, so it is still there if you come back to it. - -A program asking to *read* the clipboard is never answered. It would hand -whatever was last copied — a password, a token — to whatever is running in the -pane, and unlike writing that is something a program could not otherwise get. +Each terminal pane's toolbar has a **fit to this screen** button, the browser's half of the TUI's ` z`. It is offered only while another screen holds the sizing, because a PTY has one size for the whole session: the panes are fitted to whichever viewer opened most recently, and everyone else renders that grid until someone asks for it. Switching projects does not move it, and neither does a dropped connection coming back — a tab is one screen however many sockets it opens. Reloading the page counts as opening it, so it takes the sizing again, as a new tab would. + +Nobody holding the sizing is a state for a session with nobody in it. If every screen goes and one comes back — a phone that slept long enough for its socket to die — it takes the sizing rather than returning as a spectator, because there is no other screen to take it from. + +The panel draws its panes either side by side, as the TUI does, or one at a time behind a tab strip. The button beside **+** switches between the two, and a narrow screen starts on tabs — a split grid gives each pane fewer columns than a command line needs. Once you pick, that choice sticks on that device, rotation included; it is stored in the browser rather than on the server, because what a phone should do with four panes is not what the desktop beside it should do. + +Tabs change nothing about the session: **+** still opens a terminal that every client sees, the tabs sit in pane order, and a tab you are not looking at is a running program whose output keeps arriving. Every pane is also held at the panel's full size while tabbed, so switching tabs costs no resize — which is the same reason a tabbed browser and an attached TUI cannot both be right about how wide a pane is. Give the sizing to whichever screen you are working on with the button above, or leave the TUI holding it and read the panes at its width. + +A tabbed panel shows no **zoom** button — it already shows one pane — and a zoom another client set does not move the keyboard here. + +Drag a terminal pane by its header, or by its tab, onto another to reorder them; it works with touch as well as a mouse. The order is kept on the server, so a refresh, a reconnect, or another device opening the same repository all show the same arrangement. (It is not written to disk — a server restart clears the terminals themselves, so there is nothing to persist.) + +The **zoom** button on a pane's toolbar fills the panel with that one terminal, and the keyboard follows it. Like the order, and for the same reason, it is kept on the server: a refresh comes back to the pane you had zoomed, and another device showing the same project follows. Opening a terminal ends the zoom — the new one would be behind it otherwise — and so does closing the zoomed pane. It is not written to disk either, and cannot be: a zoom names a pane, and restarting the session ends the panes. An attached TUI keeps its own ` f` zoom rather than following this one — the panes are shared, but what fills a screen is that screen's. + +To copy from a pane, select with the mouse and press the copy key your browser already uses. A plain drag selects only while the pane's program is not reading the mouse itself; most full-screen programs do read it, and then a drag is theirs — that is how clicking a menu in one of them works at all. Hold a modifier to take the drag back for a selection: **Option** on a Mac, **Shift** everywhere else. There is nothing to copy until something is selected, so without the modifier the copy key looks broken rather than empty. + +A program running in a pane can also copy on its own — Claude Code's `/copy`, vim's OSC 52 clipboard, tmux's `copy-pipe`. That copy reaches *this* page, not the machine hosting the session, which is what makes it worth having: the `pbcopy` such a program also runs writes to a clipboard nobody at this end can reach. Most of the time it simply happens, including over plain `http://`. + +When the browser refuses to fill the clipboard without being asked — Safari wants a press for it, and any browser may — a notice appears with a **Copy** button instead, and pressing it is the press it wanted. It stays up until the text is across, so it is still there if you come back to it. + +A program asking to *read* the clipboard is never answered. It would hand whatever was last copied — a password, a token — to whatever is running in the pane, and unlike writing that is something a program could not otherwise get. ## On a phone -The three regions the desktop shows at once — the sidebar, the content pane, and -the terminal — would each shrink to an unusable sliver stacked in one column, so -instead a bottom bar switches between them: tap **Repo**, **Content**, or -**Terminal** to give one of them the whole screen. The labels name the regions -rather than what is in them: the sidebar is `status`, `log`, or `tree`, and the -content pane holds a diff, a whole file, or nothing yet. Opening a file or commit -jumps to the content pane automatically. - -**Drag a pane to scroll it.** A finger dragged up or down the terminal turns the -same wheel a mouse would, so where it goes is up to the program in the pane: an -agent or a pager that reads the wheel itself scrolls its own view, `less` and -`man` get the arrow keys they expect under alternate scroll, and a plain shell -scrolls the emulator's scrollback. That routing is the browser terminal's, matching -what the TUI does with `Shift+↑/↓` — which is why a full-screen program that keeps -its transcript in its own memory scrolls at all, rather than dragging an empty -scrollback around. A short drag is still a tap, so tapping to place the cursor and -pinching to zoom both survive. - -Because a soft keyboard can't type Escape, Tab, Shift-Tab, Ctrl combinations, or -the arrows, the terminal grows a key bar along its bottom on touch devices that -sends those straight to the shell — so you can interrupt a process (`^C`), leave -`vim` (`Esc`), reach a tmux session's prefix (`^B`), cycle a completion menu -backwards (`⇧Tab`), or walk your history (arrows) without a physical keyboard. - -**`Ctrl` on the bar is a latch, not a key.** More combinations matter than there -are buttons for, so tapping `Ctrl` lights it up — and puts the keyboard back in -the pane, since what spends it is the next character you *type* — after which -that character leaves as the combination: `Ctrl` then `a` is `^A`, and so on for -anything a terminal has a control byte for, `Ctrl+Space` and `Ctrl+[` included. -Type something with no such byte — Hangul, an emoji, more than one character — -and it goes through as you typed it. Some input leaves the latch alone -altogether — an Escape or an arrow from a hardware keyboard — because what the -program in the pane reports back to the browser arrives looking the same, and a -latch spent on that would die before you typed anything. So the light is what to -read: -`Ctrl` is armed for exactly as long as its button is lit, and tapping it again, -tapping any other key on the bar, or hiding the bar puts it out. - -**The bar is not a phone-width thing** — a tablet is as wide as a laptop and -types the same way, so what turns it on is the pointer: any device whose primary -pointer is a finger gets it, at any width, along with every window narrower than -768px. The keyboard button in the terminal panel's toolbar turns it off and on -from there, and this browser remembers which — so a desktop that wants the keys -anyway can keep them, and a tablet with a hardware keyboard attached can drop -them. - -The viewer ships a web-app manifest and icons, so you can **add it to your home -screen** and launch it as a standalone, chrome-less window — more room for the -terminal and one-tap access. On iOS this works over plain HTTP (Safari → *Share* -→ *Add to Home Screen*). Android's install prompt additionally wants a service -worker and a secure origin, so reach the viewer over HTTPS (a reverse proxy or -tunnel) to get it there; the viewer has no offline mode either way — every screen -needs the server. +The three regions the desktop shows at once — the sidebar, the content pane, and the terminal — would each shrink to an unusable sliver stacked in one column, so instead a bottom bar switches between them: tap **Repo**, **Content**, or **Terminal** to give one of them the whole screen. The labels name the regions rather than what is in them: the sidebar is `status`, `log`, or `tree`, and the content pane holds a diff, a whole file, or nothing yet. Opening a file or commit jumps to the content pane automatically. + +**Drag a pane to scroll it.** A finger dragged up or down the terminal turns the same wheel a mouse would, so where it goes is up to the program in the pane: an agent or a pager that reads the wheel itself scrolls its own view, `less` and `man` get the arrow keys they expect under alternate scroll, and a plain shell scrolls the emulator's scrollback. That routing is the browser terminal's, matching what the TUI does with `Shift+↑/↓` — which is why a full-screen program that keeps its transcript in its own memory scrolls at all, rather than dragging an empty scrollback around. A short drag is still a tap, so tapping to place the cursor and pinching to zoom both survive. + +Because a soft keyboard can't type Escape, Tab, Shift-Tab, Ctrl combinations, or the arrows, the terminal grows a key bar along its bottom on touch devices that sends those straight to the shell — so you can interrupt a process (`^C`), leave `vim` (`Esc`), reach a tmux session's prefix (`^B`), cycle a completion menu backwards (`⇧Tab`), or walk your history (arrows) without a physical keyboard. + +**`Ctrl` on the bar is a latch, not a key.** More combinations matter than there are buttons for, so tapping `Ctrl` lights it up — and puts the keyboard back in the pane, since what spends it is the next character you *type* — after which that character leaves as the combination: `Ctrl` then `a` is `^A`, and so on for anything a terminal has a control byte for, `Ctrl+Space` and `Ctrl+[` included. Type something with no such byte — Hangul, an emoji, more than one character — and it goes through as you typed it. Some input leaves the latch alone altogether — an Escape or an arrow from a hardware keyboard — because what the program in the pane reports back to the browser arrives looking the same, and a latch spent on that would die before you typed anything. So the light is what to read: `Ctrl` is armed for exactly as long as its button is lit, and tapping it again, tapping any other key on the bar, or hiding the bar puts it out. + +**The bar is not a phone-width thing** — a tablet is as wide as a laptop and types the same way, so what turns it on is the pointer: any device whose primary pointer is a finger gets it, at any width, along with every window narrower than 768px. The keyboard button in the terminal panel's toolbar turns it off and on from there, and this browser remembers which — so a desktop that wants the keys anyway can keep them, and a tablet with a hardware keyboard attached can drop them. + +The viewer ships a web-app manifest and icons, so you can **add it to your home screen** and launch it as a standalone, chrome-less window — more room for the terminal and one-tap access. On iOS this works over plain HTTP (Safari → *Share* → *Add to Home Screen*). Android's install prompt additionally wants a service worker and a secure origin, so reach the viewer over HTTPS (a reverse proxy or tunnel) to get it there; the viewer has no offline mode either way — every screen needs the server. ## Configuration and access @@ -271,51 +94,22 @@ session_ttl_hours = 24 # how long a login lasts; 0 = never expires nightcrow --port 9000 ``` -Repositories opened or closed in the browser reach every attached terminal, and -are written back to `~/.nightcrow/workspace.json` so the next session starts on -the same set. - -**Authentication.** If no `password` is set when the viewer is enabled, a random -one is generated and written back into your config (so it survives restarts and -stays readable) and printed once at startup. To avoid a plaintext password on -disk, set `hashed_password` to an Argon2 PHC string instead — it takes -precedence. Login is rate-limited and grants a session cookie. Sessions survive -a daemon restart: tokens are persisted to `~/.nightcrow/sessions` with -owner-only file permissions. Logout revokes the token server-side, so clearing -the cookie alone is not enough to invalidate a session. - -**How long a login lasts** is `session_ttl_hours`, 24 hours by default. -`session_ttl_hours = 0` means it never expires on its own — logging out, or -deleting `~/.nightcrow/sessions`, is then the only thing that ends a session. -Whether repeating the login buys anything is a judgement about your own setup: -on a loopback-bound session there may be nobody to re-authenticate against, -while a viewer reachable from another machine is shell access that a stolen -cookie opens. Two things to know either way: - -- **Lowering it reaches logins already handed out**, from the next restart — - each one's deadline is brought down to the new lifetime. Raising it never - pushes an existing deadline further away; only a fresh login gets the longer - one. -- **The cookie asks for at most 400 days** whatever the setting says. That is - the ceiling RFC 6265bis puts on `Max-Age`, and Chrome has enforced it since - version 104, so asking for more would be silently reduced there and honoured - elsewhere. A session with no expiry stays valid on the server past that — it - is the browser that will have forgotten the cookie, so you log in again. - -`[web_viewer]` is not re-read by a config reload — the listener is already -bound — so a change here takes effect when the session restarts. - -> **Security.** The viewer serves repository contents *and* interactive -> terminals, so an authenticated session is equivalent to shell access. It binds -> to loopback (`127.0.0.1`) by default and speaks plain HTTP with **no built-in -> TLS**. For remote access, do **not** expose the port directly — tunnel it over -> SSH (`ssh -L 8091:127.0.0.1:8091 host`) or put it behind a TLS reverse proxy. +Repositories opened or closed in the browser reach every attached terminal, and are written back to `~/.nightcrow/workspace.json` so the next session starts on the same set. + +**Authentication.** If no `password` is set when the viewer is enabled, a random one is generated and written back into your config (so it survives restarts and stays readable) and printed once at startup. To avoid a plaintext password on disk, set `hashed_password` to an Argon2 PHC string instead — it takes precedence. Login is rate-limited and grants a session cookie. Sessions survive a daemon restart: tokens are persisted to `~/.nightcrow/sessions` with owner-only file permissions. Logout revokes the token server-side, so clearing the cookie alone is not enough to invalidate a session. + +**How long a login lasts** is `session_ttl_hours`, 24 hours by default. `session_ttl_hours = 0` means it never expires on its own — logging out, or deleting `~/.nightcrow/sessions`, is then the only thing that ends a session. Whether repeating the login buys anything is a judgement about your own setup: on a loopback-bound session there may be nobody to re-authenticate against, while a viewer reachable from another machine is shell access that a stolen cookie opens. Two things to know either way: + +- **Lowering it reaches logins already handed out**, from the next restart — each one's deadline is brought down to the new lifetime. Raising it never pushes an existing deadline further away; only a fresh login gets the longer one. +- **The cookie asks for at most 400 days** whatever the setting says. That is the ceiling RFC 6265bis puts on `Max-Age`, and Chrome has enforced it since version 104, so asking for more would be silently reduced there and honoured elsewhere. A session with no expiry stays valid on the server past that — it is the browser that will have forgotten the cookie, so you log in again. + +`[web_viewer]` is not re-read by a config reload — the listener is already bound — so a change here takes effect when the session restarts. + +> **Security.** The viewer serves repository contents *and* interactive terminals, so an authenticated session is equivalent to shell access. It binds to loopback (`127.0.0.1`) by default and speaks plain HTTP with **no built-in TLS**. For remote access, do **not** expose the port directly — tunnel it over SSH (`ssh -L 8091:127.0.0.1:8091 host`) or put it behind a TLS reverse proxy. ## Developing the frontend -The UI lives in `viewer-ui/` (React + Vite + Tailwind). Its build output is -committed to `viewer-ui/dist/` and embedded into the binary, so installing -nightcrow never requires Node. +The UI lives in `viewer-ui/` (React + Vite + Tailwind). Its build output is committed to `viewer-ui/dist/` and embedded into the binary, so installing nightcrow never requires Node. ```bash npm --prefix viewer-ui install @@ -325,29 +119,10 @@ npm --prefix viewer-ui run build # rebuild dist/ — commit the result CI rebuilds the bundle and fails if it differs from what is committed. -**A tab open across a rebuild is told so.** Every reply to the poll the page -already makes names the build it was served with, so within a few seconds of a -rebuild the tab raises a notice with a **Reload** button and keeps it up until -you act on it. Nothing reloads itself: a tab that did would take away whatever -was being typed into a terminal, and being one build behind is not urgent enough -to interrupt anyone. - -**Until you do, the tab is still running the bundle it loaded.** Chunk names -carry a content hash, so a build replaces them rather than overwriting them, and -the markdown renderer, the HTML preview, and the terminal panel are each fetched -only when first needed — so one you open after the rebuild is simply gone. That -pane then says part of the app could not be loaded and offers the same reload. -(The same message covers a server that has become unreachable, since the browser -reports both the same way — if the reload fails too, that is which one it was.) - -**What counts as a rebuild depends on the server.** A debug server reads `dist` -from disk, so `npm --prefix viewer-ui run build` is the whole of it — reload the -tab and you are current. A release binary carries the bundle inside it and a -running process keeps the one it started with, so -[an update](getting-started.md#updating) changes nothing until the session is -restarted; that is the heavier move, since stopping the session ends its -terminals, and it is why the notice can only appear afterwards. Reloading the -tab never costs you anything — the same repositories, the same terminals, and -the pane you were typing in. +**A tab open across a rebuild is told so.** Every reply to the poll the page already makes names the build it was served with, so within a few seconds of a rebuild the tab raises a notice with a **Reload** button and keeps it up until you act on it. Nothing reloads itself: a tab that did would take away whatever was being typed into a terminal, and being one build behind is not urgent enough to interrupt anyone. + +**Until you do, the tab is still running the bundle it loaded.** Chunk names carry a content hash, so a build replaces them rather than overwriting them, and the markdown renderer, the HTML preview, and the terminal panel are each fetched only when first needed — so one you open after the rebuild is simply gone. That pane then says part of the app could not be loaded and offers the same reload. (The same message covers a server that has become unreachable, since the browser reports both the same way — if the reload fails too, that is which one it was.) + +**What counts as a rebuild depends on the server.** A debug server reads `dist` from disk, so `npm --prefix viewer-ui run build` is the whole of it — reload the tab and you are current. A release binary carries the bundle inside it and a running process keeps the one it started with, so [an update](getting-started.md#updating) changes nothing until the session is restarted; that is the heavier move, since stopping the session ends its terminals, and it is why the notice can only appear afterwards. Reloading the tab never costs you anything — the same repositories, the same terminals, and the pane you were typing in. Design notes: [Architecture → Web layer](architecture/web.md). From bcb2763b9ed9fcedc8da1384fd46285b45aaab50 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 08:51:41 +0900 Subject: [PATCH 4/9] docs: add scoped agent guidance --- .agents/rules/guardrails.md | 24 +++++++++------------ .agents/rules/testing.md | 6 +++++- AGENTS.md | 43 ++++++++++++++----------------------- docs/AGENTS.md | 7 ++++++ docs/CLAUDE.md | 1 + plugins/AGENTS.md | 16 ++++++++++++++ plugins/CLAUDE.md | 1 + src/AGENTS.md | 14 ++++++++++++ src/CLAUDE.md | 1 + viewer-ui/AGENTS.md | 15 +++++++++++++ viewer-ui/CLAUDE.md | 1 + 11 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 docs/AGENTS.md create mode 120000 docs/CLAUDE.md create mode 100644 plugins/AGENTS.md create mode 120000 plugins/CLAUDE.md create mode 100644 src/AGENTS.md create mode 120000 src/CLAUDE.md create mode 100644 viewer-ui/AGENTS.md create mode 120000 viewer-ui/CLAUDE.md diff --git a/.agents/rules/guardrails.md b/.agents/rules/guardrails.md index f4fa8cf6..86540f7e 100644 --- a/.agents/rules/guardrails.md +++ b/.agents/rules/guardrails.md @@ -1,23 +1,19 @@ +# Guardrails + ## File Size -- 모든 소스 파일(Rust, TypeScript, TSX, JavaScript)은 300줄 이하다. 테스트 파일도 예외 없다. -- 200줄 이상은 code smell이다. 분할을 검토한다. +- 모든 소스·테스트 파일(Rust, TypeScript, TSX, JavaScript)은 300줄 이하다. 테스트 파일도 예외 없다. +- 200줄 이상은 code smell이며 분할을 검토한다. - 분할은 동작을 바꾸지 않는 순수 리팩토링이어야 한다. 모듈, 순수 함수, 컴포넌트/훅으로 쪼갠다. - 생성물(`target/`, `viewer-ui/dist/`)과 벤더링한 서드파티는 제외한다. ## Platforms -- macOS, Linux, Windows 세 곳 모두에서 도는 것을 목표로 한다. 한 곳에서만 도는 코드는 - 기능이 아니라 미완성이다. CI도 세 OS를 모두 돈다 (`.github/workflows/ci.yml`). -- 플랫폼 분기는 호출부에 흩지 않고 seam 한 곳에 모은다 — 경로·시그널·스레드·로깅은 - `src/platform/`, 소켓 타입은 `src/daemon/transport.rs`. 새 분기가 필요하면 seam을 - 늘리기 전에 기존 것에 들어갈 수 있는지 먼저 본다. -- 한쪽에만 있는 API(`PermissionsExt`, `setsid`, ConPTY 동작 차이)는 대응물을 찾거나 - seam 뒤에 감춘다. 대응물이 없어 동작이 달라지면 무엇을 포기했는지 문서에 남긴다. -- 테스트를 `#[cfg(unix)]`로 막는 것은 최후 수단이다. 막는 순간 그 동작은 나머지 - 플랫폼에서 검증되지 않으므로, 왜 막았는지 주석으로 남긴다. -- Windows에서 작업 중이면 Unix 게이트는 `docker compose run --rm unix-gate`로 돌린다 - (`docs/getting-started.md`). +- macOS, Linux, Windows 세 곳 모두에서 도는 것을 목표로 한다. 한 곳에서만 도는 코드는 기능이 아니라 미완성이다. CI도 세 OS를 모두 돈다 (`.github/workflows/ci.yml`). +- 플랫폼 분기는 호출부에 흩지 않고 seam 한 곳에 모은다 — 경로·시그널·스레드·로깅은 `src/platform/`, 소켓 타입은 `src/daemon/transport.rs`. 새 분기가 필요하면 seam을 늘리기 전에 기존 것에 들어갈 수 있는지 먼저 본다. +- 한쪽에만 있는 API(`PermissionsExt`, `setsid`, ConPTY 동작 차이)는 대응물을 찾거나 seam 뒤에 감춘다. 대응물이 없어 동작이 달라지면 무엇을 포기했는지 문서에 남긴다. +- 테스트를 `#[cfg(unix)]`로 막는 것은 최후 수단이다. 막는 순간 그 동작은 나머지 플랫폼에서 검증되지 않으므로, 왜 막았는지 주석으로 남긴다. +- Windows에서 작업 중이면 Unix 게이트는 `docker compose run --rm unix-gate`로 돌린다 (`docs/getting-started.md`). ## Architecture @@ -29,7 +25,7 @@ - 가장 단순한 해결책을 먼저 시도한다. 추상화는 반복이 실제로 발생한 후에 도입한다. - 하나의 함수/모듈은 하나의 책임만 갖는다. - 매직 넘버와 하드코딩 문자열은 이름 있는 상수로 뽑는다. -- 주석은 "왜"만 남긴다. 타입이 이미 말하는 것은 반복하지 않는다. +- 코드 내부 주석은 영어로만 작성하고 동작의 "why"만 설명한다. 타입이 이미 말하는 것은 반복하지 않는다. ## Error Handling diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index 7840ce51..18d3248a 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -1,3 +1,5 @@ +# Testing + ## Which Layer - 모듈 간 계약(인터페이스)을 추가/변경 → **contract test 필수** @@ -14,7 +16,9 @@ - mock은 외부 시스템 경계에만 쓴다. - 각 테스트는 독립 실행 가능해야 한다. 테스트 간 상태 공유 금지. - 테스트 이름은 `무엇을_하면_어떤_결과가_나온다` 패턴으로 의도를 드러낸다. -- 배치·네이밍은 기존 컨벤션을 따른다. 공유 fixture/helper는 공통 위치에 둔다 (`src/test_util.rs`). +- 단위 테스트는 구현 파일에 크게 inline하지 않고 sibling `*_tests.rs` 또는 인접 `tests/`로 분리한다. crate 공개 API 통합 테스트는 루트 `tests/`에 둔다. +- TS/TSX 테스트는 sibling `*.test.ts(x)` 파일에 둔다. +- 그 밖의 배치·네이밍은 기존 컨벤션을 따른다. 공유 fixture/helper는 공통 위치에 둔다 (`src/test_util.rs`). ## Flaky Tests diff --git a/AGENTS.md b/AGENTS.md index a45acb85..c978091c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,37 +7,26 @@ Agent-adjacent Rust TUI: 상단은 git diff/commit log 뷰어, 하단은 split-v ## 에이전트 설정 -원본은 `.agents/`에 두고 도구별 디렉터리는 symlink만 둔다 (`.claude/rules`, -`.claude/skills` → `../.agents/...`). 새 도구를 붙일 때도 복사하지 말고 링크한다. -Windows에서 링크를 체크아웃하려면 개발자 모드 + `git config core.symlinks true`가 -필요하고, 없으면 링크가 경로 문자열이 담긴 일반 파일로 풀린다. +원본은 `.agents/`에 두고 도구별 디렉터리는 symlink만 둔다 (`.claude/rules`, `.claude/skills` → `../.agents/...`). 새 도구를 붙일 때도 복사하지 말고 링크한다. +Windows에서 링크를 체크아웃하려면 개발자 모드 + `git config core.symlinks true`가 필요하고, 없으면 링크가 경로 문자열이 담긴 일반 파일로 풀린다. - `.agents/rules/` — 항상 적용되는 개발 규칙. 무엇을 지킬지는 각 파일이 정한다. -- `.agents/skills/` — `/plan`, `/self-review`, `/security-review`. 각 스킬의 절차는 - 해당 `SKILL.md`가 정하므로 이 문서에 옮겨 적지 않는다. +- `.agents/skills/` — `/plan`, `/self-review`, `/security-review`. 각 스킬의 절차는 해당 `SKILL.md`가 정하므로 이 문서에 옮겨 적지 않는다. - `.agents/skills/_shared/` — 스킬이 공유하는 절차 문서. +## Scope guides + +변경 범위에 해당하는 scope guide도 함께 읽는다. + +- `docs/AGENTS.md` — `docs/` +- `src/AGENTS.md` — `src/` +- `viewer-ui/AGENTS.md` — `viewer-ui/` +- `plugins/AGENTS.md` — `plugins/` + ## 개발 흐름 -1. **Plan** — 변경이 단순하지 않으면 `/plan`으로 사용자와 정렬한 뒤 구현한다. - 단순한 버그 수정·설정 변경은 바로 구현한다. -2. **Implement** — `docs/architecture.md`의 설계 제약을 따른다. 구현이 문서와 어긋나면 - 문서를 먼저 갱신하거나 구현을 조정한다. 코드는 macOS·Linux·Windows 세 곳에서 - 도는 것을 목표로 한다 — 플랫폼 seam과 게이팅 규칙은 `.agents/rules/guardrails.md`. -3. **Verify** — `cargo build`, `cargo test`, - `cargo clippy --all-targets --all-features -- -D warnings`가 통과해야 한다. - `viewer-ui/src`를 건드렸으면 `npm --prefix viewer-ui test`와 - `npm --prefix viewer-ui run build`(dist가 안 바뀌어야 한다)도 통과해야 한다. - 훅은 두 단계로 나뉜다 (`git config core.hooksPath .githooks`). - `pre-commit`은 `cargo fmt --all --check`만 돌려 커밋을 가볍게 유지하고, - `pre-push`가 CI와 동일한 게이트를 실행한다. 막으려는 실패(붉은 CI)는 push 시점에 - 발생하므로 게이트도 그 시점에 둔다. `pre-push`는 통합 브랜치(`upstream/dev`) 대비 - 변경만 검사하므로 문서만 바꾼 push는 cargo를 아예 실행하지 않는다. - 훅은 push되는 tip만 검증한다. **각 commit이 개별적으로 green이어야 한다는 요구는 - 여전히 작성자의 몫이다** (`commits.md`). bisect할 history라면 - `NIGHTCROW_VERIFY_EACH_COMMIT=1 git push`로 범위 내 모든 commit을 검증한다. - 빌드·테스트 절차와 다른 플랫폼 게이트 돌리는 법은 `docs/getting-started.md`의 - "Building and testing" 섹션에 있다. -4. **Review** — `/self-review`로 자체 점검하고, 인증/보안/공개 API 등 민감한 변경이면 - `/security-review`도 실행한다. +1. **Plan** — 변경이 단순하지 않으면 `/plan`으로 사용자와 정렬한 뒤 구현한다. 단순한 버그 수정·설정 변경은 바로 구현한다. +2. **Implement** — `docs/architecture.md`의 설계 제약을 따른다. 구현이 문서와 어긋나면 문서를 먼저 갱신하거나 구현을 조정한다. 코드는 macOS·Linux·Windows 세 곳에서 도는 것을 목표로 한다 — 플랫폼 seam과 게이팅 규칙은 `.agents/rules/guardrails.md`. +3. **Verify** — `cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`가 통과해야 한다. `viewer-ui/src`를 건드렸으면 `npm --prefix viewer-ui test`와 `npm --prefix viewer-ui run build`(dist가 안 바뀌어야 한다)도 통과해야 한다. 훅은 두 단계로 나뉜다 (`git config core.hooksPath .githooks`). `pre-commit`은 `cargo fmt --all --check`만 돌려 커밋을 가볍게 유지하고, `pre-push`가 CI와 동일한 게이트를 실행한다. 막으려는 실패(붉은 CI)는 push 시점에 발생하므로 게이트도 그 시점에 둔다. `pre-push`는 통합 브랜치(`upstream/dev`) 대비 변경만 검사하므로 문서만 바꾼 push는 cargo를 아예 실행하지 않는다. 훅은 push되는 tip만 검증한다. **각 commit이 개별적으로 green이어야 한다는 요구는 여전히 작성자의 몫이다** (`commits.md`). bisect할 history라면 `NIGHTCROW_VERIFY_EACH_COMMIT=1 git push`로 범위 내 모든 commit을 검증한다. 빌드·테스트 절차와 다른 플랫폼 게이트 돌리는 법은 `docs/getting-started.md`의 "Building and testing" 섹션에 있다. +4. **Review** — `/self-review`로 자체 점검하고, 인증/보안/공개 API 등 민감한 변경이면 `/security-review`도 실행한다. 5. **Commit** — `.agents/rules/commits.md`를 따른다. push는 사용자가 결정한다. diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 00000000..3f33f620 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,7 @@ +# docs 범위 지침 + +`docs/` 아래 문서의 공통 작성 규칙과 문서화 범위는 [`.agents/rules/docs.md`](../.agents/rules/docs.md)를 권위 문서로 따른다. + +설계 근거, 계층 구조, 모듈 책임, 핵심 결정은 [`architecture.md`](architecture.md)에 두고, 설치·실행과 기능 사용 절차는 루트 [`README.md`](../README.md) 또는 해당 기능 문서로 라우팅한다. 설계 내용을 사용자 문서에 복제하지 말고 필요한 경우 기준 문서로 링크한다. + +Markdown prose에는 고정 열 기준 hard-wrap을 사용하지 않는다. 문단은 논리적으로 한 줄로 유지하고 표·목록·코드처럼 구조상 필요한 개행만 둔다. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md new file mode 100644 index 00000000..2eec94cf --- /dev/null +++ b/plugins/AGENTS.md @@ -0,0 +1,16 @@ +# Plugin crates + +이 문서는 `plugins/` 아래 독립적으로 빌드되는 plugin crate에 적용한다. 파일 크기, 플랫폼, 테스트 배치, 영어 주석 같은 공통 규칙은 [루트 AGENTS.md](../AGENTS.md), [guardrails.md](../.agents/rules/guardrails.md), [testing.md](../.agents/rules/testing.md)를 따르고, plugin 계약의 기준은 [Plugins](../docs/plugins.md)와 [Plugin Host](../docs/architecture/plugin-host.md)다. + +## Host 경계 + +- Plugin은 host 주소 공간에 들어가는 library가 아니라 별도 실행 프로세스다. host 내부 모듈이나 Rust ABI에 의존하지 말고, stdin/stdout의 NDJSON과 명시적 protocol version으로만 통신한다. 와이어 형태를 호환되지 않게 바꾸면 양쪽 계약을 함께 갱신하고 version mismatch를 추측으로 복구하지 않는다. +- Plugin 인스턴스는 저장소별로 실행되지만 전역 singleton이 아니다. host가 주입한 runtime directory를 사용해 plugin과 pane helper가 같은 소켓을 찾게 하며, cwd나 고정 전역 socket 경로로 다른 repository 인스턴스와 섞지 않는다. +- Pane token은 상관관계 키이지 인증 수단이 아니다. pane을 열거하거나 cwd로 대상을 추측하지 말고, helper가 제시한 token에 대한 `WatchPane` 채택과 모든 입력·relaunch 권한은 host의 guard 판단에 맡긴다. generation이 붙은 명령은 현재 spawn에만 적용한다. +- Adapter가 내놓는 입력·relaunch 계획은 제안일 뿐이다. provider 한도를 우회하거나 권한 인자를 임의로 추가하지 않으며, 사용자 설정의 허용 목록과 host의 생존·idle·generation·launch-command 검증을 전제로 한다. 손으로 provider를 시작한 pane은 기다리거나 입력할 수 있어도 재실행하지 않는다. + +## 실패 격리와 provider 경계 + +- Provider의 hook/statusline처럼 임계 경로에서 호출되는 helper는 입력 크기와 대기 시간을 제한하고 state machine이 읽는 필드만 whitelist한다. IPC나 plugin이 없어도 provider의 명령이 멈추거나 실패 메시지를 덮어쓰지 않도록 best-effort 전송과 안전한 fallback을 유지한다. +- Provider별 감지·세션 식별자·resume 인자는 plugin 안에만 둔다. 정확한 reset 시각이 있으면 한 번의 bounded wait로 처리하고, 없으면 bounded backoff로 격하한다. statusline usage 데이터는 deadline 관측에만 쓰며 한도 선언을 대신하지 않고, provider가 자체 retry 중인 동안에는 개입하지 않는다. +- 사용자 소유 설정을 수정하는 integration은 알 수 없는 JSON 키와 hook을 보존하고, 쓰기 전에 백업하며 원자적으로 교체한다. 제거 시 plugin이 식별할 수 있는 자기 항목만 제거하고, 대체한 statusline은 원본 입력 바이트를 그대로 전달해 chaining하며 `null`을 실행할 명령 없음으로 처리한다. diff --git a/plugins/CLAUDE.md b/plugins/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/plugins/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 00000000..fe0b1a5a --- /dev/null +++ b/src/AGENTS.md @@ -0,0 +1,14 @@ +# `src/` scope + +이 가이드는 `src/`의 Rust core에만 적용된다. 저장소 전체 규칙과 설계 기준은 [루트 가이드](../AGENTS.md)를 먼저 읽고, 공통 플랫폼·코드 품질 규칙은 [guardrails](../.agents/rules/guardrails.md), 테스트 배치는 [testing rules](../.agents/rules/testing.md), 전체 불변식은 [architecture index](../docs/architecture.md)를 따른다. 이 문서에는 그 규칙을 반복하지 않고 `src/`의 비자명한 경계만 적는다. + +## Core boundaries + +- `session/`은 데몬이 소유하는 transport-neutral 상태다. `application/`과 `web/`은 각자 입력·프로토콜을 session operation으로 번역하는 클라이언트이며 저장소, terminal hub, shared preference, PTY 크기 소유권을 가져가지 않는다. 세션 경계의 상세 결정은 [session design](../docs/architecture/session.md)을 기준으로 한다. +- attached TUI의 daemon socket transport와 browser viewer의 HTTP/WebSocket transport는 서로 다른 보안 경계다. 전자는 소켓 파일 권한을 전제로 하고 후자는 웹 인증을 전제로 하므로, 공통 상태 변경은 `session/`에 두되 두 transport의 인증·wire 처리를 합치지 않는다. 웹 계층의 상세는 [web design](../docs/architecture/web.md)을 따른다. +- `TerminalBackend`는 로컬 `PtyBackend`와 데몬 공유 세션의 `HubBackend`를 잇는 경계다. pane 생성·종료·재정렬·resize는 backend event 계약을 통해 관찰하고, 실제 PTY 크기는 session-level ownership과 확인된 `Resized` 이벤트를 따른다. 이 경계를 우회해 frontend가 PTY나 hub 내부 상태를 직접 갱신하지 않는다. + +## Protocol and platform seams + +- daemon frame은 control JSON과 raw terminal bytes를 구분한다. framing의 종류·길이 검증·truncated stream 처리와 terminal payload 분할 불변식을 바꾸면 [session design](../docs/architecture/session.md)과 해당 wire/contract tests를 함께 갱신한다. session repository set의 통지는 watcher 단일 producer 경계를 유지해 client별 응답 경쟁으로 순서가 갈라지지 않게 한다. +- OS 의존 동작은 기존 `platform/` seam에 모으고, daemon socket 타입의 Unix/Windows 차이는 `daemon/transport.rs` 한 곳에서 숨긴다. 호출부에 새 `cfg` 분기를 흩뿌리기 전에 기존 seam으로 흡수할 수 있는지 확인하고, 대응물이 없는 플랫폼 동작은 그 제한을 명시한다. diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/viewer-ui/AGENTS.md b/viewer-ui/AGENTS.md new file mode 100644 index 00000000..de812609 --- /dev/null +++ b/viewer-ui/AGENTS.md @@ -0,0 +1,15 @@ +# viewer-ui scope + +저장소 공통 작업 흐름은 [루트 AGENTS.md](../AGENTS.md)를 따른다. 파일 크기와 플랫폼 규칙은 [guardrails](../.agents/rules/guardrails.md), 테스트 배치와 계약 검증 규칙은 [testing](../.agents/rules/testing.md)가 정본이므로 이 문서에서 반복하지 않는다. + +## 프론트엔드 계약 + +- `viewer-ui/src/api.ts`와 `viewer-ui/src/api/`의 HTTP, SSE, WebSocket 타입·인코더·디코더는 `src/web/viewer/dto/`와 서버 terminal protocol의 반대편이다. 필드, enum variant, 메시지 순서 또는 경로를 바꾸면 양쪽 구현과 해당 contract/integration test를 함께 갱신한다. +- `api.fixture.json`은 Rust DTO에서 생성되는 커밋 대상 wire fixture다. Rust payload가 바뀌면 저장소 루트에서 `UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`로 재생성하고, fixture diff를 검토한 뒤 TypeScript API 타입을 맞춘다. fixture를 임의로 손으로 고쳐 계약 drift를 숨기지 않는다. +- API 계약 변경은 `npm --prefix viewer-ui test`와 `npm --prefix viewer-ui run build`로 확인한다. `api.contract.test.ts`의 타입 대입은 누락·이름 변경·타입 변경을 잡고, Rust fixture test는 서버가 추가하거나 제거한 payload를 고정한다. + +## 번들 및 개발 서버 + +- `viewer-ui/dist/`는 Vite가 생성하는 커밋 대상 릴리스 번들이며 Rust 서버가 바이너리에 임베드한다. 번들에 영향을 주는 소스·설정·public asset 변경 뒤에는 반드시 build하고, 최종 변경에는 소스와 일치하는 `dist` 결과를 포함한다. clean checkout에서 같은 build를 다시 실행해 `dist`에 미커밋 차이가 없어야 한다. +- `vite.config.ts`의 relative asset base와 `/api`, `/login`, `/ws` 개발 프록시는 임베드 서버와 Vite 개발 서버 사이의 배포 계약이다. mount path나 서버 포트를 바꿀 때는 Rust route와 문서·검증을 함께 확인한다. +- 화면 조립은 `pages/`, 재사용 UI는 `components/`, 상태·효과는 `hooks/`, API 외 순수 로직은 `lib/`에 둔다. 서버 wire 문자열을 각 hook에서 다시 해석하지 말고 `api/`의 경계에서 검증된 타입을 전달한다. diff --git a/viewer-ui/CLAUDE.md b/viewer-ui/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/viewer-ui/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From b3118d5776721995510ba76a5edf3f056dab5c08 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 09:21:02 +0900 Subject: [PATCH 5/9] docs: clarify code comment guidance --- .agents/rules/guardrails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/rules/guardrails.md b/.agents/rules/guardrails.md index 86540f7e..47715b94 100644 --- a/.agents/rules/guardrails.md +++ b/.agents/rules/guardrails.md @@ -25,7 +25,7 @@ - 가장 단순한 해결책을 먼저 시도한다. 추상화는 반복이 실제로 발생한 후에 도입한다. - 하나의 함수/모듈은 하나의 책임만 갖는다. - 매직 넘버와 하드코딩 문자열은 이름 있는 상수로 뽑는다. -- 코드 내부 주석은 영어로만 작성하고 동작의 "why"만 설명한다. 타입이 이미 말하는 것은 반복하지 않는다. +- 코드 내부 주석은 영어로만 작성하고 핵심적인 "why"만 설명한다. 코드·타입·이름만 보고 알 수 있는 동작은 주석으로 반복하지 않는다. ## Error Handling From 83210de8339c85368de1bbb2da0fcd21014f431c Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 09:21:07 +0900 Subject: [PATCH 6/9] fix(attach): reuse the handshake connection --- src/application/attach.rs | 4 +- src/cli/attach.rs | 115 +++++++++++++++++++++++++++++-------- src/daemon/client.rs | 59 +++++++++++++++++-- src/daemon/client_tests.rs | 16 ++++++ 4 files changed, 163 insertions(+), 31 deletions(-) diff --git a/src/application/attach.rs b/src/application/attach.rs index f11bba9f..c8b86c7a 100644 --- a/src/application/attach.rs +++ b/src/application/attach.rs @@ -14,9 +14,7 @@ use anyhow::Result; use syntect::highlighting::ThemeSet; /// Attach to the daemon and run the TUI until the user leaves or it goes away. -pub(crate) fn run_attach() -> Result<()> { - let client = DaemonClient::connect(&crate::daemon::socket::default_socket_path()?)?; - +pub(crate) fn run_attach(client: DaemonClient) -> Result<()> { let cfg = crate::config::load_config()?; // Parsed before the alternate screen so its error is readable. The // configured startup terminals are not read here at all: the daemon runs diff --git a/src/cli/attach.rs b/src/cli/attach.rs index ce9c3da8..9f3fe47e 100644 --- a/src/cli/attach.rs +++ b/src/cli/attach.rs @@ -1,3 +1,4 @@ +use crate::daemon::client::{ConnectError, DaemonClient}; use anyhow::Result; use std::path::Path; use std::time::{Duration, Instant}; @@ -7,34 +8,43 @@ const DAEMON_READY_TIMEOUT: Duration = Duration::from_secs(20); /// Start a session when needed, then attach to it. pub(crate) fn run_attach_detached() -> Result<()> { let socket = crate::daemon::socket::default_socket_path()?; - if daemon_accepts(&socket) { - return crate::application::attach::run_attach(); - } - let log = super::daemon::daemon_output_path()?; - let pid = crate::daemon::detach::respawn_in_background(&log)?; - eprintln!("nightcrow: started a session in the background (pid {pid})"); - eprintln!("nightcrow: its output goes to {}", log.display()); - wait_for_daemon(&socket, &log)?; - crate::application::attach::run_attach() + let client = connect_or_start(&socket, &log)?; + crate::application::attach::run_attach(client) } -fn daemon_accepts(socket: &Path) -> bool { - crate::daemon::transport::UnixStream::connect(socket).is_ok() +fn connect_or_start(socket: &Path, log: &Path) -> Result { + let first_unavailable = match DaemonClient::connect_for_attach(socket) { + Ok(client) => return Ok(client), + Err(ConnectError::Failed(err)) => return Err(err), + Err(ConnectError::Unavailable(err)) => err, + }; + + let pid = crate::daemon::detach::respawn_in_background(log)?; + eprintln!("nightcrow: started a session in the background (pid {pid})"); + eprintln!("nightcrow: its output goes to {}", log.display()); + wait_for_daemon(socket, log, first_unavailable) } -fn wait_for_daemon(socket: &Path, log: &Path) -> Result<()> { +fn wait_for_daemon( + socket: &Path, + log: &Path, + mut last_unavailable: anyhow::Error, +) -> Result { let deadline = Instant::now() + DAEMON_READY_TIMEOUT; while Instant::now() < deadline { - if daemon_accepts(socket) { - return Ok(()); + match DaemonClient::connect_for_attach(socket) { + Ok(client) => return Ok(client), + Err(ConnectError::Failed(err)) => return Err(err), + Err(ConnectError::Unavailable(err)) => last_unavailable = err, } std::thread::sleep(Duration::from_millis(50)); } anyhow::bail!( - "the session did not start within {}s — see {}", + "the session did not start within {}s — see {}; last connection error: {}", DAEMON_READY_TIMEOUT.as_secs(), - log.display() + log.display(), + last_unavailable ) } @@ -47,19 +57,78 @@ mod tests { let dir = tempfile::TempDir::new().expect("a temp dir"); let path = dir.path().join("nightcrow.sock"); - assert!(!daemon_accepts(&path)); + assert!(matches!( + DaemonClient::connect_for_attach(&path), + Err(ConnectError::Unavailable(_)) + )); std::fs::write(&path, b"").expect("write stale socket stand-in"); - assert!(!daemon_accepts(&path)); + assert!(matches!( + DaemonClient::connect_for_attach(&path), + Err(ConnectError::Unavailable(_)) + )); } #[test] - fn a_bound_socket_reads_as_a_running_daemon() { + fn a_running_daemon_is_handshaken_once_for_the_actual_attach_client() { let dir = tempfile::TempDir::new().expect("a temp dir"); let path = dir.path().join("live.sock"); - let _listener = - crate::daemon::transport::UnixListener::bind(&path).expect("bind probe socket"); + let daemon = crate::daemon::socket::DaemonSocket::bind(&path).expect("bind daemon"); + let listener = daemon + .listener() + .try_clone() + .expect("clone daemon listener"); + let server = std::thread::spawn(move || handshake_and_count_clients(listener)); + + let _client = connect_or_start(&path, dir.path()).expect("attach succeeds"); + assert_eq!(server.join().expect("server succeeds"), 1); + } - assert!(daemon_accepts(&path)); - wait_for_daemon(&path, dir.path()).expect("already accepting"); + fn handshake_and_count_clients(listener: crate::daemon::transport::UnixListener) -> usize { + use crate::daemon::frame::{Frame, read_frame, write_frame}; + use crate::daemon::protocol::{ClientMessage, ServerMessage, version}; + use std::io::Write; + + listener + .set_nonblocking(true) + .expect("make listener nonblocking"); + let deadline = Instant::now() + Duration::from_secs(2); + let mut accepted = 0; + let mut handshaken = false; + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + accepted += 1; + stream.set_nonblocking(false).expect("make stream blocking"); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("set handshake timeout"); + if let Ok(Some(frame)) = read_frame(&mut stream) { + let message: ClientMessage = + serde_json::from_slice(&frame.payload).expect("decode hello"); + assert!(matches!(message, ClientMessage::Hello { .. })); + let hello = serde_json::to_vec(&ServerMessage::Hello { + version: version(), + client: 1, + }) + .expect("encode hello"); + write_frame(&mut stream, &Frame::control(hello)).expect("write hello"); + stream.flush().expect("flush hello"); + handshaken = true; + } + if handshaken { + break; + } + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(err) => panic!("accepting attach client: {err}"), + } + } + assert!( + handshaken, + "the actual attach client never completed handshake" + ); + accepted } } diff --git a/src/daemon/client.rs b/src/daemon/client.rs index 37c8b431..cc24bc25 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -20,6 +20,25 @@ use std::time::Duration; /// daemon is the normal state. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); +/// Why opening a daemon client failed, so an attach caller can retry only +/// when there was no listener to connect to. A listener that rejects or fails +/// the handshake is still a daemon failure, not an invitation to start a +/// second daemon beside it. +#[derive(Debug)] +pub(crate) enum ConnectError { + Unavailable(anyhow::Error), + Failed(anyhow::Error), +} + +impl ConnectError { + #[cfg(test)] + fn into_error(self) -> anyhow::Error { + match self { + Self::Unavailable(err) | Self::Failed(err) => err, + } + } +} + #[derive(Debug)] pub struct DaemonClient { out: Writer, @@ -40,13 +59,36 @@ impl DaemonClient { /// before returning, so a caller that gets a `DaemonClient` knows it is /// talking to a daemon of this build. The repository set the daemon /// volunteers on attach is queued like any other message. + #[cfg(test)] pub fn connect(path: &Path) -> Result { - let stream = UnixStream::connect(path).with_context(|| { - format!( - "no nightcrow daemon on {} — start one with `nightcrow serve`", - path.display() - ) + Self::connect_for_attach(path).map_err(ConnectError::into_error) + } + + /// Connect for `nightcrow attach`, preserving whether the socket itself + /// was unavailable or the daemon failed after accepting the connection. + /// The former may start a background daemon; the latter must be reported. + pub(crate) fn connect_for_attach(path: &Path) -> std::result::Result { + let stream = UnixStream::connect(path).map_err(|err| { + let unavailable = is_unavailable_socket_error(&err); + let context = if unavailable { + format!( + "no nightcrow daemon on {} — start one with `nightcrow serve`", + path.display() + ) + } else { + format!("connecting to the nightcrow daemon on {}", path.display()) + }; + let error = anyhow::Error::new(err).context(context); + if unavailable { + ConnectError::Unavailable(error) + } else { + ConnectError::Failed(error) + } })?; + Self::connect_stream(stream).map_err(ConnectError::Failed) + } + + fn connect_stream(stream: UnixStream) -> Result { let mut reader = stream .try_clone() .context("splitting the daemon connection")?; @@ -206,6 +248,13 @@ impl DaemonClient { } } +fn is_unavailable_socket_error(error: &std::io::Error) -> bool { + matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) +} + #[cfg(test)] #[path = "client_tests.rs"] mod tests; diff --git a/src/daemon/client_tests.rs b/src/daemon/client_tests.rs index 6a5324f5..612a4f4b 100644 --- a/src/daemon/client_tests.rs +++ b/src/daemon/client_tests.rs @@ -91,6 +91,22 @@ fn attaching_where_no_daemon_listens_says_so() { ); } +#[test] +fn only_missing_or_refused_sockets_are_attach_startup_failures() { + assert!(super::is_unavailable_socket_error(&std::io::Error::from( + std::io::ErrorKind::NotFound, + ))); + assert!(super::is_unavailable_socket_error(&std::io::Error::from( + std::io::ErrorKind::ConnectionRefused, + ))); + assert!(!super::is_unavailable_socket_error(&std::io::Error::from( + std::io::ErrorKind::PermissionDenied, + ))); + assert!(!super::is_unavailable_socket_error(&std::io::Error::from( + std::io::ErrorKind::InvalidInput, + ))); +} + #[test] fn opening_a_repository_comes_back_as_a_broadcast() { let (repo, path) = crate::test_util::make_repo(); From 857dee5314c825097f22ff0b55b7e8d4679c2125 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 09:21:12 +0900 Subject: [PATCH 7/9] fix(attach): coalesce fragmented terminal output --- src/daemon/terminal_link.rs | 35 +- src/daemon/terminal_link/coalescing.rs | 41 +++ src/daemon/terminal_link_tests.rs | 316 ------------------ src/daemon/terminal_link_tests/coalescing.rs | 145 ++++++++ src/daemon/terminal_link_tests/draining.rs | 102 ++++++ src/daemon/terminal_link_tests/measurement.rs | 74 ++++ src/daemon/terminal_link_tests/mod.rs | 26 ++ src/daemon/terminal_link_tests/overflow.rs | 45 +++ src/daemon/terminal_link_tests/routing.rs | 79 +++++ 9 files changed, 539 insertions(+), 324 deletions(-) create mode 100644 src/daemon/terminal_link/coalescing.rs delete mode 100644 src/daemon/terminal_link_tests.rs create mode 100644 src/daemon/terminal_link_tests/coalescing.rs create mode 100644 src/daemon/terminal_link_tests/draining.rs create mode 100644 src/daemon/terminal_link_tests/measurement.rs create mode 100644 src/daemon/terminal_link_tests/mod.rs create mode 100644 src/daemon/terminal_link_tests/overflow.rs create mode 100644 src/daemon/terminal_link_tests/routing.rs diff --git a/src/daemon/terminal_link.rs b/src/daemon/terminal_link.rs index 505c967a..d3181d64 100644 --- a/src/daemon/terminal_link.rs +++ b/src/daemon/terminal_link.rs @@ -7,6 +7,7 @@ //! inbox — on the render tick, where it must never wait on a socket. use super::protocol::ClientMessage; +mod coalescing; use super::wire::{Writer, send}; use crate::backend::PaneId; use crate::session::terminal::frame::{ @@ -111,6 +112,10 @@ impl TerminalRouter { /// and the socket reader ends the connection. A later attach is replayed a /// coherent stream by the session hub; continuing after a partial drop /// could never be repaired. + /// + /// Adjacent output frames for one pane coalesce within that repository + /// while their combined payload fits one drain chunk. A single oversized + /// incoming frame remains a single message so replay cannot wedge here. pub(crate) fn deliver( &self, repo: &str, @@ -118,11 +123,22 @@ impl TerminalRouter { ) -> Result<(), TerminalInboxOverflow> { let incoming = message.output_bytes(); let mut state = self.state.lock().expect("terminal inboxes poisoned"); + // A PTY read is not a protocol boundary: ConPTY can split one burst + // into thousands of adjacent one-byte reads. Keep one queue entry per + // contiguous pane run within this repository, bounded to one drain + // chunk. This keeps the message ceiling protecting control traffic + // and pane boundaries without turning read granularity into a + // disconnect condition. + let coalesces = coalescing::fits( + state.inboxes.get(repo).and_then(VecDeque::back), + &message, + TERMINAL_DRAIN_BYTES, + ); let bytes_fit = state .queued_output_bytes .checked_add(incoming) .is_some_and(|total| total <= self.byte_limit); - let messages_fit = state.queued_messages < self.message_limit; + let messages_fit = coalesces || state.queued_messages < self.message_limit; if state.overflowed || !bytes_fit || !messages_fit { state.overflowed = true; return Err(TerminalInboxOverflow { @@ -134,12 +150,15 @@ impl TerminalRouter { }); } state.queued_output_bytes += incoming; - state.queued_messages += 1; - state - .inboxes - .entry(repo.to_string()) - .or_default() - .push_back(message); + if !coalesces { + state.queued_messages += 1; + } + let inbox = state.inboxes.entry(repo.to_string()).or_default(); + if coalesces { + coalescing::append_to_fitting_tail(inbox, message); + } else { + inbox.push_back(message); + } Ok(()) } @@ -267,5 +286,5 @@ impl TerminalLink { } #[cfg(test)] -#[path = "terminal_link_tests.rs"] +#[path = "terminal_link_tests/mod.rs"] mod tests; diff --git a/src/daemon/terminal_link/coalescing.rs b/src/daemon/terminal_link/coalescing.rs new file mode 100644 index 00000000..96cac87e --- /dev/null +++ b/src/daemon/terminal_link/coalescing.rs @@ -0,0 +1,41 @@ +use super::TerminalMessage; +use std::collections::VecDeque; + +pub(super) fn fits( + tail: Option<&TerminalMessage>, + incoming: &TerminalMessage, + max_bytes: usize, +) -> bool { + match (tail, incoming) { + ( + Some(TerminalMessage::Output { + pane: queued_pane, + data: queued_data, + }), + TerminalMessage::Output { pane, data }, + ) => { + queued_pane == pane + && queued_data + .len() + .checked_add(data.len()) + .is_some_and(|total| total <= max_bytes) + } + _ => false, + } +} + +pub(super) fn append_to_fitting_tail( + inbox: &mut VecDeque, + message: TerminalMessage, +) { + let TerminalMessage::Output { data, .. } = message else { + unreachable!("coalescing only applies to output messages"); + }; + let Some(TerminalMessage::Output { + data: queued_data, .. + }) = inbox.back_mut() + else { + unreachable!("coalescing requires a queued output message"); + }; + queued_data.extend(data); +} diff --git a/src/daemon/terminal_link_tests.rs b/src/daemon/terminal_link_tests.rs deleted file mode 100644 index a383f37b..00000000 --- a/src/daemon/terminal_link_tests.rs +++ /dev/null @@ -1,316 +0,0 @@ -use super::*; -use crate::session::terminal::frame::ServerMessage as HubServerMessage; - -fn created(pane: PaneId) -> TerminalMessage { - TerminalMessage::Event(HubServerMessage::Created { - pane, - rows: 24, - cols: 80, - client: None, - title: None, - }) -} - -fn pane_of(message: &TerminalMessage) -> PaneId { - match message { - TerminalMessage::Event(HubServerMessage::Created { pane, .. }) => *pane, - TerminalMessage::Output { pane, .. } => *pane, - other => panic!("expected a pane message, got {other:?}"), - } -} - -#[test] -fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { - // The daemon subscribes a client to every open repository the moment it - // connects, so a pane and its scrollback can be on the wire before the - // client has been told the repository exists. The replay happens once — - // dropping it would lose those panes for good. - let router = TerminalRouter::default(); - - router.deliver("r1", created(1)).unwrap(); - router - .deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: b"prompt$ ".to_vec(), - }, - ) - .unwrap(); - - let inbox = router.drain("r1"); - assert_eq!(inbox.len(), 2); - assert_eq!(pane_of(&inbox[0]), 1); -} - -#[test] -fn each_repository_drains_only_its_own_traffic() { - let router = TerminalRouter::default(); - router.deliver("r1", created(1)).unwrap(); - router.deliver("r2", created(2)).unwrap(); - - let first = router.drain("r1"); - assert_eq!(first.len(), 1); - assert_eq!(pane_of(&first[0]), 1); - let second = router.drain("r2"); - assert_eq!(second.len(), 1); - assert_eq!(pane_of(&second[0]), 2); -} - -#[test] -fn a_drained_inbox_is_empty_until_more_arrives() { - let router = TerminalRouter::default(); - router.deliver("r1", created(1)).unwrap(); - - assert_eq!(router.drain("r1").len(), 1); - assert!(router.drain("r1").is_empty()); - assert!( - router.drain("never-heard-of-it").is_empty(), - "and an unknown repository is empty rather than a panic" - ); -} - -#[test] -fn closing_a_repository_drops_what_was_queued_for_it() { - // Its backend went with its tab, so nothing will ever drain this. - let router = TerminalRouter::default(); - router.deliver("r1", created(1)).unwrap(); - router.deliver("gone", created(9)).unwrap(); - - router.retain(&["r1".to_string()]); - - assert_eq!(router.drain("r1").len(), 1); - assert!(router.drain("gone").is_empty()); -} - -#[test] -fn a_drain_takes_a_bounded_fifo_prefix() { - let router = TerminalRouter::default(); - for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { - router.deliver("r1", created(pane)).unwrap(); - } - - let first = router.drain("r1"); - assert_eq!(first.len(), TERMINAL_DRAIN_MESSAGES); - assert_eq!(pane_of(&first[0]), 1); - assert_eq!( - pane_of(first.last().expect("the bounded batch is not empty")), - TERMINAL_DRAIN_MESSAGES as PaneId - ); - let second = router.drain("r1"); - assert_eq!(second.len(), 1); - assert_eq!(pane_of(&second[0]), TERMINAL_DRAIN_MESSAGES as PaneId + 1); -} - -#[test] -fn a_busy_repository_does_not_spend_another_repositories_budget() { - let router = TerminalRouter::default(); - for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { - router.deliver("busy", created(pane)).unwrap(); - } - router.deliver("quiet", created(999)).unwrap(); - - assert_eq!(router.drain("busy").len(), TERMINAL_DRAIN_MESSAGES); - let quiet = router.drain("quiet"); - assert_eq!(quiet.len(), 1); - assert_eq!(pane_of(&quiet[0]), 999); -} - -#[test] -fn the_byte_budget_leaves_the_next_output_for_the_next_drain() { - let router = TerminalRouter::default(); - let chunk = vec![b'x'; TERMINAL_DRAIN_BYTES / 2]; - for pane in 1..=3 { - router - .deliver( - "r1", - TerminalMessage::Output { - pane, - data: chunk.clone(), - }, - ) - .unwrap(); - } - - let first = router.drain("r1"); - assert_eq!(first.len(), 2); - assert_eq!(pane_of(&first[0]), 1); - assert_eq!(pane_of(&first[1]), 2); - let second = router.drain("r1"); - assert_eq!(second.len(), 1); - assert_eq!(pane_of(&second[0]), 3); -} - -#[test] -fn an_oversized_head_advances_but_exit_waits_behind_its_output() { - let router = TerminalRouter::default(); - let output = vec![b'x'; TERMINAL_DRAIN_BYTES + 1]; - router - .deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: output.clone(), - }, - ) - .unwrap(); - router - .deliver( - "r1", - TerminalMessage::Event(HubServerMessage::Exited { pane: 1 }), - ) - .unwrap(); - - let first = router.drain("r1"); - assert!(matches!( - first.as_slice(), - [TerminalMessage::Output { pane: 1, data }] if data == &output - )); - assert!(matches!( - router.drain("r1").as_slice(), - [TerminalMessage::Event(HubServerMessage::Exited { pane: 1 })] - )); -} - -#[test] -fn crossing_the_byte_ceiling_poison_disconnects_instead_of_making_a_hole() { - let router = TerminalRouter::with_byte_limit(4); - router - .deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: b"abcd".to_vec(), - }, - ) - .unwrap(); - - let overflow = router.deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: b"e".to_vec(), - }, - ); - assert!(overflow.is_err()); - assert!( - router.deliver("r1", created(2)).is_err(), - "after one rejected frame, accepting later traffic would create a stream hole" - ); - - let kept = router.drain("r1"); - assert!(matches!( - kept.as_slice(), - [TerminalMessage::Output { data, .. }] if data == b"abcd" - )); -} - -#[test] -fn control_only_traffic_is_bounded_too() { - let router = TerminalRouter::with_limits(usize::MAX, 1); - router.deliver("r1", created(1)).unwrap(); - - assert!( - router.deliver("r1", created(2)).is_err(), - "control events consume memory even though they carry no PTY bytes" - ); -} - -#[test] -fn closing_a_repository_returns_its_share_of_the_byte_allowance() { - let router = TerminalRouter::with_byte_limit(4); - router - .deliver( - "gone", - TerminalMessage::Output { - pane: 1, - data: b"abcd".to_vec(), - }, - ) - .unwrap(); - - router.retain(&[]); - - router - .deliver( - "open", - TerminalMessage::Output { - pane: 2, - data: b"wxyz".to_vec(), - }, - ) - .expect("discarding an unopened repository frees its queued bytes"); -} - -#[test] -#[ignore = "release-only terminal inbox load measurement"] -fn measure_one_mb_per_second_terminal_inbox_drain() { - const BYTES_PER_SECOND: usize = 1_000_000; - const FRAMES_PER_SECOND: usize = 60; - const OUTPUT_CHUNK_BYTES: usize = 1024; - const FRAME_BUDGET_NS: u128 = 1_000_000_000 / FRAMES_PER_SECOND as u128; - - let router = TerminalRouter::default(); - let mut produced_bytes = 0usize; - let mut drained_bytes = 0usize; - let mut peak_queued_bytes = 0usize; - let mut peak_queued_messages = 0usize; - let mut drain_calls = 0usize; - let mut total_drain_ns = 0u128; - let mut max_drain_ns = 0u128; - - for frame in 0..FRAMES_PER_SECOND { - let frame_bytes = BYTES_PER_SECOND / FRAMES_PER_SECOND - + usize::from(frame < BYTES_PER_SECOND % FRAMES_PER_SECOND); - let mut remaining = frame_bytes; - while remaining > 0 { - let chunk_bytes = remaining.min(OUTPUT_CHUNK_BYTES); - router - .deliver( - "r1", - TerminalMessage::Output { - pane: 1, - data: vec![b'x'; chunk_bytes], - }, - ) - .unwrap(); - produced_bytes += chunk_bytes; - remaining -= chunk_bytes; - } - - let (queued_bytes, queued_messages) = router.queued_for_test(); - peak_queued_bytes = peak_queued_bytes.max(queued_bytes); - peak_queued_messages = peak_queued_messages.max(queued_messages); - - let started = std::time::Instant::now(); - let drained = router.drain("r1"); - let elapsed_ns = started.elapsed().as_nanos(); - drain_calls += 1; - total_drain_ns += elapsed_ns; - max_drain_ns = max_drain_ns.max(elapsed_ns); - drained_bytes += drained - .iter() - .map(TerminalMessage::output_bytes) - .sum::(); - } - - let (remaining_bytes, remaining_messages) = router.queued_for_test(); - let max_frame_budget_basis_points = max_drain_ns * 10_000 / FRAME_BUDGET_NS; - println!( - "1 MB/s terminal inbox over {FRAMES_PER_SECOND} simulated frames: \ - peak_queued_bytes={peak_queued_bytes} peak_queued_messages={peak_queued_messages} \ - drain_calls={drain_calls} total_drain_ns={total_drain_ns} \ - max_drain_ns={max_drain_ns} frame_budget_ns={FRAME_BUDGET_NS} \ - max_frame_budget_basis_points={max_frame_budget_basis_points}" - ); - - assert_eq!(produced_bytes, BYTES_PER_SECOND); - assert_eq!(drained_bytes, BYTES_PER_SECOND); - assert_eq!( - peak_queued_bytes, - BYTES_PER_SECOND.div_ceil(FRAMES_PER_SECOND) - ); - assert_eq!(peak_queued_messages, 17); - assert_eq!(drain_calls, FRAMES_PER_SECOND); - assert_eq!((remaining_bytes, remaining_messages), (0, 0)); -} diff --git a/src/daemon/terminal_link_tests/coalescing.rs b/src/daemon/terminal_link_tests/coalescing.rs new file mode 100644 index 00000000..c3a4d5b9 --- /dev/null +++ b/src/daemon/terminal_link_tests/coalescing.rs @@ -0,0 +1,145 @@ +use super::*; + +#[test] +fn adjacent_tiny_outputs_for_one_repository_pane_coalesce_past_the_message_limit() { + let router = TerminalRouter::with_limits(usize::MAX, TERMINAL_INBOX_MESSAGES); + let chunk_count = TERMINAL_INBOX_MESSAGES + 1; + let expected: Vec<_> = (0..chunk_count).map(|index| (index % 251) as u8).collect(); + + for byte in &expected { + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![*byte], + }, + ) + .unwrap(); + } + + assert_eq!(router.queued_for_test(), (chunk_count, 1)); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == &expected + )); +} + +#[test] +fn per_repository_output_coalescing_stops_at_pane_and_event_boundaries() { + let router = TerminalRouter::default(); + for (repo, pane, data) in [("r1", 1, b"a".as_slice()), ("r2", 1, b"b".as_slice())] { + router + .deliver( + repo, + TerminalMessage::Output { + pane, + data: data.to_vec(), + }, + ) + .unwrap(); + } + // A different repository's arrival does not break r1's own adjacent run. + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"c".to_vec(), + }, + ) + .unwrap(); + router.deliver("r1", created(2)).unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"d".to_vec(), + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 2, + data: b"e".to_vec(), + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 2, + data: b"f".to_vec(), + }, + ) + .unwrap(); + + let r1 = router.drain("r1"); + assert!(matches!( + r1.as_slice(), + [ + TerminalMessage::Output { pane: 1, data: before_event }, + TerminalMessage::Event(HubServerMessage::Created { pane: 2, .. }), + TerminalMessage::Output { pane: 1, data: after_event }, + TerminalMessage::Output { pane: 2, data: last }, + ] if before_event == b"ac" && after_event == b"d" && last == b"ef" + )); + assert!(matches!( + router.drain("r2").as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == b"b" + )); +} + +#[test] +fn per_repository_coalescing_splits_after_exactly_one_drain_chunk() { + let router = TerminalRouter::default(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'a'; TERMINAL_DRAIN_BYTES - 1], + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'b'], + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'c'], + }, + ) + .unwrap(); + + assert_eq!( + router.queued_for_test(), + (TERMINAL_DRAIN_BYTES + 1, 2), + "the first two chunks fit exactly, while the third starts a new message" + ); + let first = router.drain("r1"); + assert!(matches!( + first.as_slice(), + [TerminalMessage::Output { pane: 1, data }] + if data.len() == TERMINAL_DRAIN_BYTES + && data[..TERMINAL_DRAIN_BYTES - 1].iter().all(|&byte| byte == b'a') + && data[TERMINAL_DRAIN_BYTES - 1] == b'b' + )); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == b"c" + )); +} diff --git a/src/daemon/terminal_link_tests/draining.rs b/src/daemon/terminal_link_tests/draining.rs new file mode 100644 index 00000000..255780c1 --- /dev/null +++ b/src/daemon/terminal_link_tests/draining.rs @@ -0,0 +1,102 @@ +use super::*; + +#[test] +fn a_drain_takes_a_bounded_fifo_prefix() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("r1", created(pane)).unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), TERMINAL_DRAIN_MESSAGES); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!( + pane_of(first.last().expect("the bounded batch is not empty")), + TERMINAL_DRAIN_MESSAGES as PaneId + ); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), TERMINAL_DRAIN_MESSAGES as PaneId + 1); +} + +#[test] +fn the_byte_budget_leaves_the_next_output_for_the_next_drain() { + let router = TerminalRouter::default(); + let chunk = vec![b'x'; TERMINAL_DRAIN_BYTES / 2]; + for pane in 1..=3 { + router + .deliver( + "r1", + TerminalMessage::Output { + pane, + data: chunk.clone(), + }, + ) + .unwrap(); + } + + let first = router.drain("r1"); + assert_eq!(first.len(), 2); + assert_eq!(pane_of(&first[0]), 1); + assert_eq!(pane_of(&first[1]), 2); + let second = router.drain("r1"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), 3); +} + +#[test] +fn an_oversized_head_advances_but_exit_waits_behind_its_output() { + let router = TerminalRouter::default(); + let output = vec![b'x'; TERMINAL_DRAIN_BYTES + 1]; + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: output.clone(), + }, + ) + .unwrap(); + router + .deliver( + "r1", + TerminalMessage::Event(HubServerMessage::Exited { pane: 1 }), + ) + .unwrap(); + + let first = router.drain("r1"); + assert!(matches!( + first.as_slice(), + [TerminalMessage::Output { pane: 1, data }] if data == &output + )); + assert!(matches!( + router.drain("r1").as_slice(), + [TerminalMessage::Event(HubServerMessage::Exited { pane: 1 })] + )); +} + +#[test] +fn closing_a_repository_returns_its_share_of_the_byte_allowance() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "gone", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + router.retain(&[]); + + router + .deliver( + "open", + TerminalMessage::Output { + pane: 2, + data: b"wxyz".to_vec(), + }, + ) + .expect("discarding an unopened repository frees its queued bytes"); +} diff --git a/src/daemon/terminal_link_tests/measurement.rs b/src/daemon/terminal_link_tests/measurement.rs new file mode 100644 index 00000000..6c66c61d --- /dev/null +++ b/src/daemon/terminal_link_tests/measurement.rs @@ -0,0 +1,74 @@ +use super::*; + +#[test] +#[ignore = "release-only terminal inbox load measurement"] +fn measure_one_mb_per_second_terminal_inbox_drain() { + const BYTES_PER_SECOND: usize = 1_000_000; + const FRAMES_PER_SECOND: usize = 60; + const OUTPUT_CHUNK_BYTES: usize = 1024; + const FRAME_BUDGET_NS: u128 = 1_000_000_000 / FRAMES_PER_SECOND as u128; + + let router = TerminalRouter::default(); + let mut produced_bytes = 0usize; + let mut drained_bytes = 0usize; + let mut peak_queued_bytes = 0usize; + let mut peak_queued_messages = 0usize; + let mut drain_calls = 0usize; + let mut total_drain_ns = 0u128; + let mut max_drain_ns = 0u128; + + for frame in 0..FRAMES_PER_SECOND { + let frame_bytes = BYTES_PER_SECOND / FRAMES_PER_SECOND + + usize::from(frame < BYTES_PER_SECOND % FRAMES_PER_SECOND); + let mut remaining = frame_bytes; + while remaining > 0 { + let chunk_bytes = remaining.min(OUTPUT_CHUNK_BYTES); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: vec![b'x'; chunk_bytes], + }, + ) + .unwrap(); + produced_bytes += chunk_bytes; + remaining -= chunk_bytes; + } + + let (queued_bytes, queued_messages) = router.queued_for_test(); + peak_queued_bytes = peak_queued_bytes.max(queued_bytes); + peak_queued_messages = peak_queued_messages.max(queued_messages); + + let started = std::time::Instant::now(); + let drained = router.drain("r1"); + let elapsed_ns = started.elapsed().as_nanos(); + drain_calls += 1; + total_drain_ns += elapsed_ns; + max_drain_ns = max_drain_ns.max(elapsed_ns); + drained_bytes += drained + .iter() + .map(TerminalMessage::output_bytes) + .sum::(); + } + + let (remaining_bytes, remaining_messages) = router.queued_for_test(); + let max_frame_budget_basis_points = max_drain_ns * 10_000 / FRAME_BUDGET_NS; + println!( + "1 MB/s terminal inbox over {FRAMES_PER_SECOND} simulated frames: \ + peak_queued_bytes={peak_queued_bytes} peak_queued_messages={peak_queued_messages} \ + drain_calls={drain_calls} total_drain_ns={total_drain_ns} \ + max_drain_ns={max_drain_ns} frame_budget_ns={FRAME_BUDGET_NS} \ + max_frame_budget_basis_points={max_frame_budget_basis_points}" + ); + + assert_eq!(produced_bytes, BYTES_PER_SECOND); + assert_eq!(drained_bytes, BYTES_PER_SECOND); + assert_eq!( + peak_queued_bytes, + BYTES_PER_SECOND.div_ceil(FRAMES_PER_SECOND) + ); + assert_eq!(peak_queued_messages, 1); + assert_eq!(drain_calls, FRAMES_PER_SECOND); + assert_eq!((remaining_bytes, remaining_messages), (0, 0)); +} diff --git a/src/daemon/terminal_link_tests/mod.rs b/src/daemon/terminal_link_tests/mod.rs new file mode 100644 index 00000000..4bea0c02 --- /dev/null +++ b/src/daemon/terminal_link_tests/mod.rs @@ -0,0 +1,26 @@ +use super::*; +use crate::session::terminal::frame::ServerMessage as HubServerMessage; + +fn created(pane: PaneId) -> TerminalMessage { + TerminalMessage::Event(HubServerMessage::Created { + pane, + rows: 24, + cols: 80, + client: None, + title: None, + }) +} + +fn pane_of(message: &TerminalMessage) -> PaneId { + match message { + TerminalMessage::Event(HubServerMessage::Created { pane, .. }) => *pane, + TerminalMessage::Output { pane, .. } => *pane, + other => panic!("expected a pane message, got {other:?}"), + } +} + +mod coalescing; +mod draining; +mod measurement; +mod overflow; +mod routing; diff --git a/src/daemon/terminal_link_tests/overflow.rs b/src/daemon/terminal_link_tests/overflow.rs new file mode 100644 index 00000000..c202d0f8 --- /dev/null +++ b/src/daemon/terminal_link_tests/overflow.rs @@ -0,0 +1,45 @@ +use super::*; + +#[test] +fn crossing_the_byte_ceiling_poison_disconnects_instead_of_making_a_hole() { + let router = TerminalRouter::with_byte_limit(4); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"abcd".to_vec(), + }, + ) + .unwrap(); + + let overflow = router.deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"e".to_vec(), + }, + ); + assert!(overflow.is_err()); + assert!( + router.deliver("r1", created(2)).is_err(), + "after one rejected frame, accepting later traffic would create a stream hole" + ); + + let kept = router.drain("r1"); + assert!(matches!( + kept.as_slice(), + [TerminalMessage::Output { data, .. }] if data == b"abcd" + )); +} + +#[test] +fn control_only_traffic_is_bounded_too() { + let router = TerminalRouter::with_limits(usize::MAX, 1); + router.deliver("r1", created(1)).unwrap(); + + assert!( + router.deliver("r1", created(2)).is_err(), + "control events consume memory even though they carry no PTY bytes" + ); +} diff --git a/src/daemon/terminal_link_tests/routing.rs b/src/daemon/terminal_link_tests/routing.rs new file mode 100644 index 00000000..12f3a551 --- /dev/null +++ b/src/daemon/terminal_link_tests/routing.rs @@ -0,0 +1,79 @@ +use super::*; + +#[test] +fn traffic_that_arrives_before_a_repository_has_a_reader_is_kept() { + // The daemon subscribes a client to every open repository the moment it + // connects, so a pane and its scrollback can be on the wire before the + // client has been told the repository exists. The replay happens once -- + // dropping it would lose those panes for good. + let router = TerminalRouter::default(); + + router.deliver("r1", created(1)).unwrap(); + router + .deliver( + "r1", + TerminalMessage::Output { + pane: 1, + data: b"prompt$ ".to_vec(), + }, + ) + .unwrap(); + + let inbox = router.drain("r1"); + assert_eq!(inbox.len(), 2); + assert_eq!(pane_of(&inbox[0]), 1); +} + +#[test] +fn each_repository_drains_only_its_own_traffic() { + let router = TerminalRouter::default(); + router.deliver("r1", created(1)).unwrap(); + router.deliver("r2", created(2)).unwrap(); + + let first = router.drain("r1"); + assert_eq!(first.len(), 1); + assert_eq!(pane_of(&first[0]), 1); + let second = router.drain("r2"); + assert_eq!(second.len(), 1); + assert_eq!(pane_of(&second[0]), 2); +} + +#[test] +fn a_drained_inbox_is_empty_until_more_arrives() { + let router = TerminalRouter::default(); + router.deliver("r1", created(1)).unwrap(); + + assert_eq!(router.drain("r1").len(), 1); + assert!(router.drain("r1").is_empty()); + assert!( + router.drain("never-heard-of-it").is_empty(), + "and an unknown repository is empty rather than a panic" + ); +} + +#[test] +fn closing_a_repository_drops_what_was_queued_for_it() { + // Its backend went with its tab, so nothing will ever drain this. + let router = TerminalRouter::default(); + router.deliver("r1", created(1)).unwrap(); + router.deliver("gone", created(9)).unwrap(); + + router.retain(&["r1".to_string()]); + + assert_eq!(router.drain("r1").len(), 1); + assert!(router.drain("gone").is_empty()); +} + +#[test] +fn a_busy_repository_does_not_spend_another_repositories_budget() { + let router = TerminalRouter::default(); + for pane in 1..=TERMINAL_DRAIN_MESSAGES as PaneId + 1 { + router.deliver("busy", created(pane)).unwrap(); + } + router.deliver("quiet", created(999)).unwrap(); + + assert_eq!(router.drain("busy").len(), TERMINAL_DRAIN_MESSAGES); + let quiet = router.drain("quiet"); + assert_eq!(quiet.len(), 1); + assert_eq!(pane_of(&quiet[0]), 999); +} From 25b7fc76df83db6379d481ad9528dec67fe01113 Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 14:15:36 +0900 Subject: [PATCH 8/9] feat: add daemon status and simplify recovery Add a read-only daemon status query, remove obsolete Claude recovery integration, and preserve Codex/OpenCode recovery. Compress project guidance and split oversized or duplicated implementation and test responsibilities. --- .agents/rules/commits.md | 10 +- .agents/rules/dependencies.md | 2 + .agents/rules/docs.md | 13 +- .agents/rules/guardrails.md | 1 - .agents/rules/security.md | 2 + .agents/rules/testing.md | 6 +- .agents/skills/_shared/review-protocol.md | 7 +- .agents/skills/plan/SKILL.md | 4 + .agents/skills/security-review/SKILL.md | 4 + .agents/skills/self-review/SKILL.md | 4 + AGENTS.md | 19 +- Cargo.lock | 3 - README.md | 144 ++------- config.example.toml | 20 +- docs/AGENTS.md | 8 +- docs/README.md | 38 +-- docs/architecture.md | 233 +++----------- docs/architecture/git-views.md | 65 +--- docs/architecture/plugin-host.md | 59 ++-- docs/architecture/session.md | 148 ++------- docs/architecture/terminal.md | 70 ++-- docs/architecture/ui.md | 95 ++---- docs/architecture/web.md | 184 +++-------- docs/configuration.md | 188 ++++------- docs/decisions.md | 160 +++------ docs/getting-started.md | 111 +++---- docs/keybindings.md | 148 +++------ docs/plugins.md | 63 +--- docs/projects.md | 18 +- docs/session-state.md | 26 +- docs/views.md | 66 +--- docs/web-viewer.md | 127 +------- plugins/AGENTS.md | 9 +- plugins/nightcrow-recovery/Cargo.toml | 5 - plugins/nightcrow-recovery/src/helper.rs | 173 ---------- .../nightcrow-recovery/src/helper_delegate.rs | 182 ----------- .../src/helper_statusline.rs | 123 ------- .../src/helper_statusline_tests.rs | 203 ------------ .../nightcrow-recovery/src/helper_tests.rs | 134 -------- plugins/nightcrow-recovery/src/hooks.rs | 233 -------------- plugins/nightcrow-recovery/src/hooks_merge.rs | 283 ---------------- .../src/hooks_merge_tests.rs | 240 -------------- plugins/nightcrow-recovery/src/hooks_tests.rs | 276 ---------------- plugins/nightcrow-recovery/src/ipc.rs | 303 ------------------ plugins/nightcrow-recovery/src/ipc_tests.rs | 221 ------------- plugins/nightcrow-recovery/src/main.rs | 78 +---- plugins/nightcrow-recovery/src/protocol.rs | 31 +- .../nightcrow-recovery/src/protocol_tests.rs | 16 +- .../nightcrow-recovery/src/provider/claude.rs | 258 --------------- .../src/provider/claude_output_tests.rs | 108 ------- .../src/provider/claude_tests.rs | 222 ------------- .../src/provider/codex_output_tests.rs | 2 - .../src/provider/codex_pane.rs | 14 +- .../src/provider/codex_rollout.rs | 23 +- .../src/provider/codex_rollout_tests.rs | 14 +- .../src/provider/codex_tests.rs | 4 +- .../nightcrow-recovery/src/provider/mod.rs | 96 +----- .../src/provider/mod_tests.rs | 49 +-- .../src/provider/opencode.rs | 8 +- .../src/provider/opencode_http.rs | 8 - .../src/provider/opencode_http_tests.rs | 35 +- .../src/provider/opencode_tests.rs | 6 +- plugins/nightcrow-recovery/src/runloop.rs | 137 +------- .../nightcrow-recovery/src/runloop_adopt.rs | 102 ------ .../src/runloop_adopt_tests.rs | 128 -------- plugins/nightcrow-recovery/src/runloop_io.rs | 2 - plugins/nightcrow-recovery/src/state.rs | 33 +- plugins/nightcrow-recovery/src/state_clock.rs | 8 +- .../nightcrow-recovery/src/state_resume.rs | 28 +- .../src/state_tests/cancel.rs | 9 - .../nightcrow-recovery/src/state_tests/mod.rs | 35 +- .../src/state_tests/resume.rs | 65 +--- .../src/state_tests/transitions.rs | 28 -- plugins/nightcrow-recovery/src/transport.rs | 5 - plugins/nightcrow-recovery/src/wait.rs | 5 +- src/AGENTS.md | 2 +- src/app/log_nav.rs | 92 +++--- src/app/tests/log_search.rs | 42 +++ src/application/session_link.rs | 3 + src/application/session_terminals_tests.rs | 3 +- src/cli.rs | 8 + src/cli/daemon.rs | 2 +- src/cli/status.rs | 129 ++++++++ src/cli/status_render.rs | 166 ++++++++++ src/cli/status_render_tests.rs | 111 +++++++ src/cli/status_tests.rs | 91 ++++++ src/cli/stop.rs | 28 +- src/cli/stop_tests.rs | 2 +- src/daemon/client.rs | 28 +- src/daemon/client_tests.rs | 3 +- src/daemon/clients.rs | 10 + src/daemon/mod.rs | 8 + src/daemon/one_shot.rs | 52 +++ src/daemon/one_shot_tests.rs | 86 +++++ src/daemon/protocol.rs | 12 +- src/daemon/protocol/status.rs | 42 +++ src/daemon/protocol_tests.rs | 69 +++- src/daemon/requests.rs | 22 +- src/daemon/serve.rs | 110 ++----- src/daemon/serve/admission.rs | 57 ++++ src/daemon/serve/admission_tests.rs | 20 ++ src/daemon/serve/connection.rs | 128 ++++++++ src/daemon/serve/pre_attach.rs | 68 ++++ src/daemon/serve_tests/accent.rs | 1 + src/daemon/serve_tests/harness.rs | 26 +- src/daemon/serve_tests/mod.rs | 1 + src/daemon/serve_tests/session.rs | 33 +- src/daemon/serve_tests/status.rs | 164 ++++++++++ src/daemon/serve_tests/terminals.rs | 1 + src/daemon/status.rs | 69 ++++ src/daemon/status_tests.rs | 25 ++ src/main.rs | 5 +- src/plugin/host.rs | 66 +--- src/plugin/host_command.rs | 74 +++++ src/plugin/mod.rs | 1 + src/runtime/terminal/tests/mod.rs | 1 + .../tests/panes_from_elsewhere_tests.rs | 100 ++++++ src/runtime/terminal/tests/poll_tests.rs | 103 ------ src/session/catalog/views.rs | 22 ++ src/session/mod.rs | 2 +- src/session/operations.rs | 2 +- src/session/state.rs | 12 + src/session/terminal/hub_plugins_slots.rs | 10 +- src/session/terminal/mod.rs | 13 +- src/web/viewer/mod.rs | 2 +- src/web/viewer/server/tests/auth.rs | 6 +- viewer-ui/AGENTS.md | 4 +- 127 files changed, 2393 insertions(+), 5732 deletions(-) delete mode 100644 plugins/nightcrow-recovery/src/helper.rs delete mode 100644 plugins/nightcrow-recovery/src/helper_delegate.rs delete mode 100644 plugins/nightcrow-recovery/src/helper_statusline.rs delete mode 100644 plugins/nightcrow-recovery/src/helper_statusline_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/helper_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/hooks.rs delete mode 100644 plugins/nightcrow-recovery/src/hooks_merge.rs delete mode 100644 plugins/nightcrow-recovery/src/hooks_merge_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/hooks_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/ipc.rs delete mode 100644 plugins/nightcrow-recovery/src/ipc_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/provider/claude.rs delete mode 100644 plugins/nightcrow-recovery/src/provider/claude_output_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/provider/claude_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/runloop_adopt.rs delete mode 100644 plugins/nightcrow-recovery/src/runloop_adopt_tests.rs delete mode 100644 plugins/nightcrow-recovery/src/transport.rs create mode 100644 src/cli/status.rs create mode 100644 src/cli/status_render.rs create mode 100644 src/cli/status_render_tests.rs create mode 100644 src/cli/status_tests.rs create mode 100644 src/daemon/one_shot.rs create mode 100644 src/daemon/one_shot_tests.rs create mode 100644 src/daemon/protocol/status.rs create mode 100644 src/daemon/serve/admission.rs create mode 100644 src/daemon/serve/admission_tests.rs create mode 100644 src/daemon/serve/connection.rs create mode 100644 src/daemon/serve/pre_attach.rs create mode 100644 src/daemon/serve_tests/status.rs create mode 100644 src/daemon/status.rs create mode 100644 src/daemon/status_tests.rs create mode 100644 src/plugin/host_command.rs create mode 100644 src/runtime/terminal/tests/panes_from_elsewhere_tests.rs diff --git a/.agents/rules/commits.md b/.agents/rules/commits.md index 0a3a60e1..4f8a2cb8 100644 --- a/.agents/rules/commits.md +++ b/.agents/rules/commits.md @@ -1,11 +1,13 @@ +# Commit and History Rules + ## Commit Units - 하나의 commit은 하나의 목적만 담고, 독립적으로 리뷰·revert 가능해야 한다. - 큰 작업도 작은 commit으로 나눈다. 단, 의미 있는 작업 단위가 깨질 정도로 쪼개지 않는다. -- 각 commit 시점에 빌드와 테스트가 통과해야 한다 (AGENTS.md의 Verify 게이트). - 훅은 이것을 강제하지 않는다 — `pre-commit`은 형식만 보고, 전체 게이트는 `pre-push`가 - tip에 대해서만 돌린다. 따라서 이 항목은 도구가 아니라 작성자가 지키는 규칙이며, - 깨지면 `git bisect`가 못 쓰게 된다. 범위 전체를 검증하려면 +- 각 commit 시점에 빌드와 테스트가 통과해야 한다. 일반 게이트는 루트 `AGENTS.md`가 가리키는 `docs/getting-started.md`에 있고, 훅이 이 원칙을 대신 지키지는 않는다. + 훅은 이것을 강제하지 않는다 — `pre-commit`은 형식만 보고, `pre-push`는 통합 기준점 대비 + 변경 범위의 tip만 검사한다(문서만 바꾼 push는 Rust 게이트를 건너뛴다). 따라서 이 항목은 + 도구가 아니라 작성자가 지키는 규칙이며, 깨지면 `git bisect`가 못 쓰게 된다. 범위 전체를 검증하려면 `NIGHTCROW_VERIFY_EACH_COMMIT=1 git push`. ## Feature-scoped Workflow diff --git a/.agents/rules/dependencies.md b/.agents/rules/dependencies.md index 26db7ab0..5793ef7a 100644 --- a/.agents/rules/dependencies.md +++ b/.agents/rules/dependencies.md @@ -1,3 +1,5 @@ +# Dependency Rules + ## Selecting - 새 의존성 전에 stdlib 또는 이미 있는 의존성으로 되는지 먼저 확인한다. diff --git a/.agents/rules/docs.md b/.agents/rules/docs.md index 2e13314a..0f02d18f 100644 --- a/.agents/rules/docs.md +++ b/.agents/rules/docs.md @@ -1,20 +1,15 @@ -## What Exists +# Documentation Rules -- `README.md` — 무엇인지, 설치·실행, 사전 조건. 처음 보는 사람이 5분 안에 로컬 실행 가능한 수준. -- `docs/architecture.md` — 계층 구조, 모듈 책임, 핵심 설계 결정과 그 이유. -- `docs/` 나머지 — 기능별 사용 문서. -- 필요 없는 문서 유형을 새로 만들지 않는다. 유지보수할 수 없는 문서는 만들지 않는다. -- 형식적으로 빈 섹션(Contributing, License 등)을 채우지 않는다. - -## What to Document +## Content - 공개 인터페이스의 계약: 입력, 출력, 에러, 부작용. - 비자명한 제약: 순서 의존성, 호출 전제 조건, 스레드/동시성 안전성. - 코드가 표현하지 못하는 맥락과 "왜". - 내부용 함수는 이름과 시그니처가 명확하면 문서화하지 않는다. 타입이 말하는 것을 주석으로 반복하지 않는다. +- 필요한 문서만 유지한다. 유지보수할 수 없는 문서나 형식적인 빈 섹션(Contributing, License 등)은 만들지 않는다. ## Quality -- 틀린 문서는 없는 문서보다 나쁘다. 코드 변경으로 내용이 달라지면 같은 작업 안에서 갱신한다. +- 문서는 현재 코드와 일치해야 한다. 코드 변경으로 내용이 달라지면 같은 작업 안에서 갱신한다. - 예시 코드는 실제로 실행 가능한 상태를 유지한다. - 추측이나 미래 계획을 사실처럼 쓰지 않는다. diff --git a/.agents/rules/guardrails.md b/.agents/rules/guardrails.md index 47715b94..c78cd3e6 100644 --- a/.agents/rules/guardrails.md +++ b/.agents/rules/guardrails.md @@ -18,7 +18,6 @@ ## Architecture - `docs/architecture.md`가 설계 결정의 기준이다. 구현이 문서와 어긋나면 문서를 먼저 고치거나 구현을 조정한다. -- top-level 구조는 새 모듈을 붙일 수 있도록 열어 두되, 초기 구현은 간소하게 시작한다. ## Code Quality diff --git a/.agents/rules/security.md b/.agents/rules/security.md index cd05660b..8e04f29b 100644 --- a/.agents/rules/security.md +++ b/.agents/rules/security.md @@ -1,3 +1,5 @@ +# Security Rules + ## Input Validation - 시스템 경계(사용자 입력, 외부 API 응답, 파일 읽기, HTTP 요청)에서 오는 데이터는 항상 검증한다. diff --git a/.agents/rules/testing.md b/.agents/rules/testing.md index 18d3248a..d8f0fb6f 100644 --- a/.agents/rules/testing.md +++ b/.agents/rules/testing.md @@ -2,11 +2,7 @@ ## Which Layer -- 모듈 간 계약(인터페이스)을 추가/변경 → **contract test 필수** -- 순수 함수, 개별 모듈 로직 → unit test -- API endpoint, 요청 흐름 전체(web viewer, daemon protocol) → integration test -- 사용자 관점 시나리오 → end-to-end test -- 하나의 변경이 여러 유형에 걸치면 각각 작성한다. +- 변경 유형에 맞는 테스트를 추가한다: 모듈 간 계약(인터페이스)은 **contract test**, 순수 함수·개별 모듈 로직은 unit test, API endpoint·전체 요청 흐름(web viewer·daemon protocol)은 integration test, 사용자 관점 시나리오는 end-to-end test. 하나의 변경이 여러 유형에 걸치면 각각 작성한다. ## Rules diff --git a/.agents/skills/_shared/review-protocol.md b/.agents/skills/_shared/review-protocol.md index e04c6587..35fad1c5 100644 --- a/.agents/skills/_shared/review-protocol.md +++ b/.agents/skills/_shared/review-protocol.md @@ -29,8 +29,7 @@ ## 3. 수정 적용 -- 수정 후 AGENTS.md의 Verify 게이트(`cargo build`, `cargo test`, `cargo clippy --all-targets - --all-features -- -D warnings`)를 실행해 다른 것이 깨지지 않았는지 확인한다. +- 수정 후 루트 `AGENTS.md`가 가리키는 Verify 게이트(`docs/getting-started.md#building-and-testing`)를 실행해 다른 것이 깨지지 않았는지 확인한다. - 테스트가 실패하면 원인을 먼저 분류한다. - 수정이 원인: 수정을 되돌리고 **사용자 판단 필요**로 재분류한다. - 기존 flaky 또는 환경 문제: 수정을 유지하고 실패 원인을 보고한다. @@ -46,13 +45,17 @@ ## 5. 보고 형식 ### 즉시 반영한 항목 + (각 항목: 파일, 변경 내용, 발견 근거. 없으면 `없음`) ### 사용자 판단이 필요한 항목 + (각 항목: 파일, 지적 내용, 판단을 미룬 이유. 없으면 `없음`) ### 무시한 항목 + (건수와 대표 사유. 없으면 `없음`) ### 리뷰 요약 + (1-2문장 평가) diff --git a/.agents/skills/plan/SKILL.md b/.agents/skills/plan/SKILL.md index 2f1edc18..2861bb74 100644 --- a/.agents/skills/plan/SKILL.md +++ b/.agents/skills/plan/SKILL.md @@ -35,15 +35,19 @@ user-invocable: true 계획을 다음 형식으로 보고하고 구현 전 정렬한다. ### 목표 및 제약 + (정리된 목표와 제약) ### 접근 방식 + (선택한 방식과 이유. 대안이 있었으면 비교 요약) ### 구현 계획 + (번호가 매겨진 단계별 목록) ### 불확실한 점 + (추가 확인이 필요한 사항. 없으면 `없음`) 사용자가 계획을 승인하면 구현을 시작한다. 수정 요청이 있으면 계획을 조정한 후 재확인한다. diff --git a/.agents/skills/security-review/SKILL.md b/.agents/skills/security-review/SKILL.md index 13e7698d..70cd2749 100644 --- a/.agents/skills/security-review/SKILL.md +++ b/.agents/skills/security-review/SKILL.md @@ -16,20 +16,24 @@ user-invocable: true 먼저 `security.md`의 규칙 준수 여부를 변경된 코드에서 확인하고, 그 위에 다음을 본다. ### 입력과 주입 + - 시스템 경계(사용자 입력, 외부 API 응답, 파일 경로, URL 파라미터, 헤더)의 검증 여부. - Command Injection, XSS, Path Traversal 등 OWASP Top 10 노출 경로. - 신뢰할 수 없는 입력의 역직렬화. ### 인증 및 권한 + - 인증 우회 가능성, 권한 검사가 빠진 엔드포인트·명령. - 세션/토큰의 만료, 무효화, 저장 방식. 권한 상승 경로. ### 정보 노출 + - 키/토큰 하드코딩. 민감 정보가 로그, 에러 메시지, 응답 본문, 커밋 히스토리에 새는지. - 에러 응답의 내부 세부사항(스택 트레이스, 내부 경로) 노출. - 에러 처리가 보안 검사를 우회하는 경로를 만드는지. ### 노출면 + - 로컬 daemon 소켓과 web viewer의 바인드 주소·권한이 필요한 최소인지. - CORS, CSP 등 브라우저 보안 정책 설정. - root/admin 권한을 요구하는 구현, 불필요하게 넓은 권한을 요구하는 의존성. diff --git a/.agents/skills/self-review/SKILL.md b/.agents/skills/self-review/SKILL.md index 8535193e..ff542db7 100644 --- a/.agents/skills/self-review/SKILL.md +++ b/.agents/skills/self-review/SKILL.md @@ -14,17 +14,20 @@ user-invocable: true ## 분석 렌즈 (extended thinking) ### 정합성 + - 호출하는 함수, 의존하는 타입, 참조하는 상수가 실제로 존재하고 올바른지. - 새 인터페이스/타입과 기존 구현체 간 계약이 맞는지. - import 경로, export 누락, 순환 참조. ### 로직 + - 분기의 완전성 (switch/if-else). - 에러 경로에서의 리소스 정리와 상태 롤백. - 경계 조건 (null, empty, 0, max). - 비동기 코드의 await 누락, 에러 전파 누락. ### 설계 정합성 + - `docs/architecture.md`의 계층 책임과 일치하는지. - `.agents/rules/`의 규칙을 위반하지 않는지. - scope 문서가 있으면 그 범위 내인지. @@ -32,6 +35,7 @@ user-invocable: true - 문서 간 충돌은 Architecture > Rules > Scope 우선순위로 해소한다. ### 테스트 충분성 + - 변경된 로직의 주요 경로와 에러 경로에 대응하는 테스트가 있는지. - 테스트가 구현 세부사항이 아니라 계약/동작을 검증하는지. diff --git a/AGENTS.md b/AGENTS.md index c978091c..1214a13b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,20 +3,17 @@ 체크아웃 루트에 `AGENTS.local.md`가 있으면 이 문서와 함께 읽고 적용한다. Agent-adjacent Rust TUI: 상단은 git diff/commit log 뷰어, 하단은 split-view 멀티 터미널 패널. -설계는 `docs/architecture.md`, 사용법은 `README.md`. +설계 기준은 `docs/architecture.md`, 설치·실행과 사용법은 `README.md`와 `docs/`다. ## 에이전트 설정 -원본은 `.agents/`에 두고 도구별 디렉터리는 symlink만 둔다 (`.claude/rules`, `.claude/skills` → `../.agents/...`). 새 도구를 붙일 때도 복사하지 말고 링크한다. -Windows에서 링크를 체크아웃하려면 개발자 모드 + `git config core.symlinks true`가 필요하고, 없으면 링크가 경로 문자열이 담긴 일반 파일로 풀린다. +원본은 `.agents/`에 두고 도구별 디렉터리는 symlink만 둔다 (`.claude/rules`, `.claude/skills` → `../.agents/...`). 새 도구를 붙일 때도 복사하지 말고 링크한다. Windows에서 링크를 체크아웃하려면 개발자 모드와 `git config core.symlinks true`가 필요하다. 그렇지 않으면 링크가 경로 문자열을 담은 일반 파일로 풀린다. -- `.agents/rules/` — 항상 적용되는 개발 규칙. 무엇을 지킬지는 각 파일이 정한다. -- `.agents/skills/` — `/plan`, `/self-review`, `/security-review`. 각 스킬의 절차는 해당 `SKILL.md`가 정하므로 이 문서에 옮겨 적지 않는다. -- `.agents/skills/_shared/` — 스킬이 공유하는 절차 문서. +`.agents/rules/`는 항상 적용되는 규칙, `.agents/skills/`는 `/plan`, `/self-review`, `/security-review` 절차다. 스킬 공통 절차는 `.agents/skills/_shared/`에서 관리하며 이 문서에 복제하지 않는다. ## Scope guides -변경 범위에 해당하는 scope guide도 함께 읽는다. +변경 범위에 해당하는 scope guide도 함께 읽는다. 공통 규칙을 scope guide에 다시 적지 않는다. - `docs/AGENTS.md` — `docs/` - `src/AGENTS.md` — `src/` @@ -26,7 +23,7 @@ Windows에서 링크를 체크아웃하려면 개발자 모드 + `git config cor ## 개발 흐름 1. **Plan** — 변경이 단순하지 않으면 `/plan`으로 사용자와 정렬한 뒤 구현한다. 단순한 버그 수정·설정 변경은 바로 구현한다. -2. **Implement** — `docs/architecture.md`의 설계 제약을 따른다. 구현이 문서와 어긋나면 문서를 먼저 갱신하거나 구현을 조정한다. 코드는 macOS·Linux·Windows 세 곳에서 도는 것을 목표로 한다 — 플랫폼 seam과 게이팅 규칙은 `.agents/rules/guardrails.md`. -3. **Verify** — `cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`가 통과해야 한다. `viewer-ui/src`를 건드렸으면 `npm --prefix viewer-ui test`와 `npm --prefix viewer-ui run build`(dist가 안 바뀌어야 한다)도 통과해야 한다. 훅은 두 단계로 나뉜다 (`git config core.hooksPath .githooks`). `pre-commit`은 `cargo fmt --all --check`만 돌려 커밋을 가볍게 유지하고, `pre-push`가 CI와 동일한 게이트를 실행한다. 막으려는 실패(붉은 CI)는 push 시점에 발생하므로 게이트도 그 시점에 둔다. `pre-push`는 통합 브랜치(`upstream/dev`) 대비 변경만 검사하므로 문서만 바꾼 push는 cargo를 아예 실행하지 않는다. 훅은 push되는 tip만 검증한다. **각 commit이 개별적으로 green이어야 한다는 요구는 여전히 작성자의 몫이다** (`commits.md`). bisect할 history라면 `NIGHTCROW_VERIFY_EACH_COMMIT=1 git push`로 범위 내 모든 commit을 검증한다. 빌드·테스트 절차와 다른 플랫폼 게이트 돌리는 법은 `docs/getting-started.md`의 "Building and testing" 섹션에 있다. -4. **Review** — `/self-review`로 자체 점검하고, 인증/보안/공개 API 등 민감한 변경이면 `/security-review`도 실행한다. -5. **Commit** — `.agents/rules/commits.md`를 따른다. push는 사용자가 결정한다. +2. **Implement** — `docs/architecture.md`와 해당 scope guide의 경계를 따른다. 공통 플랫폼·코드 품질 제약은 `.agents/rules/guardrails.md`에 있다. +3. **Verify** — 빌드·테스트·포맷·다른 플랫폼·viewer bundle 게이트는 [`docs/getting-started.md`](docs/getting-started.md)의 [Building and testing](docs/getting-started.md#building-and-testing)을 따른다. 커밋별 green과 history 규칙은 [`commits.md`](.agents/rules/commits.md)에 있다. +4. **Review** — `/self-review`로 자체 점검하고, 인증·보안·공개 API 등 민감한 변경이면 `/security-review`도 실행한다. 각 스킬의 절차는 해당 `SKILL.md`를 따른다. +5. **Commit** — [`.agents/rules/commits.md`](.agents/rules/commits.md)를 따른다. push는 사용자가 결정한다. diff --git a/Cargo.lock b/Cargo.lock index ca585cbc..c5a6ea20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1306,12 +1306,9 @@ name = "nightcrow-recovery" version = "0.1.1" dependencies = [ "anyhow", - "clap", - "dirs", "serde", "serde_json", "tempfile", - "uds_windows", ] [[package]] diff --git a/README.md b/README.md index 2075f23f..904631c9 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,62 @@ # nightcrow -Agent-adjacent terminal workbench — git diff viewer, commit log, and multi-pane terminal multiplexer in one window. Tuned for sitting next to LLM CLIs (Claude Code, Codex, aider) or any process that touches your working tree, but nightcrow itself has no AI ontology — it watches files and PTYs, not agents. +Agent-adjacent terminal workbench: inspect Git changes while running several terminal programs beside them. A session owns the repositories and PTYs; a TUI and the browser viewer can attach to the same session. Closing either client leaves the session running. -nightcrow runs as a **session**: one process holds the repositories and the terminals, and you reach it from a terminal (`nightcrow attach`) or a browser. Closing a client leaves the session running. +nightcrow is a single Rust binary for macOS, Linux, and Windows. -Runs on **macOS, Linux, and Windows** — one Rust binary, the same TUI and the same browser view on all three. +## Quick start -``` - ~/projects/myapp main ↑2 ↓0 -┌──────────────────────────────────────────────────────┐ -│ Files │ @@ -36,7 +36,12 @@ │ -│ M src/app.rs │ fn collect_hunks( │ -│ M src/diff.rs │ - mut on_file: impl FnMut(...), │ -│▶MM src/main.rs │ + on_file: impl FnMut(...) │ -├──────────────────────────────────────────────────────┤ -│ [1] claude [2] aider [3] bash │ -│ $ cargo test │ -└──────────────────────────────────────────────────────┘ - j/k: scroll | /: search | v: view file | q: detach +### 1. Install + +```bash +cargo install --git https://github.com/code0xff/nightcrow --locked ``` -## Install +Rust 1.85 or newer is required. To create an editable starter configuration first, run `nightcrow init`; see [Configuration](docs/configuration.md). -The built viewer bundle is committed, so this needs no Node toolchain: +### 2. Start a session ```bash -cargo install --git https://github.com/code0xff/nightcrow --locked +nightcrow attach ``` -Requires Rust 1.85+ (edition 2024) on macOS, Linux, or Windows. Other install -routes are in [Getting started](docs/getting-started.md#install). +`attach` starts a background session when none is running, then opens the TUI. If a session already exists it attaches to that session instead of starting another one. The session prints its browser URL and the path used by later `nightcrow attach` commands. The web viewer uses a generated password on first start and prints it once; see [Web viewer](docs/web-viewer.md#access-and-security). -To update later, use `nightcrow update` rather than rerunning the install: on -Windows the plain install cannot overwrite the binary while a session is -running. See [Updating](docs/getting-started.md#updating). +With the default leader (`Ctrl+F`), press the leader and then: -## Quick start +- ` o` opens a repository as a project tab. +- ` t` opens a terminal pane. +- ` l` and ` b` switch to the commit log and tree views. +- ` f` toggles fullscreen and ` q` detaches the TUI. + +The complete reference is [Keyboard and mouse](docs/keybindings.md). + +### 3. Stop or update ```bash -# The one-command way in: attach the TUI, starting a backgrounded session -# first if none is running. If one already is, it attaches to that one -# instead of starting a second. It reopens the repositories from last time. -nightcrow attach +nightcrow stop # stop the running session and its terminal programs +nightcrow update # reinstall the binary; restart the session afterwards ``` -The pieces on their own, when you want them separately: +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. -```bash -# Just the session, backgrounded — returns your shell. -nightcrow -d +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, endpoint, attached clients, repositories, and panes. It exits non-zero when no daemon is running. -# From another terminal: bring up the TUI on that session. -nightcrow attach # same command — it attaches to the session already running +## Features -# Foreground, for a service manager or to watch the startup output. -nightcrow +- Up to 10 repository tabs, each with its own Git views and terminal panes → [Projects](docs/projects.md). +- Status, commit log, and read-only tree views → [Views](docs/views.md). +- Shared session state, recent-activity highlighting, and restart behavior → [Session state](docs/session-state.md). +- Configurable layout, input, shell, logging, startup commands, plugins, and web access → [Configuration](docs/configuration.md). +- A browser surface for the same repositories and interactive terminals → [Web viewer](docs/web-viewer.md). +- Optional external plugins, including bundled recovery that waits out Codex/OpenCode usage limits and reopens exact sessions → [Plugins](docs/plugins.md). -# Ask a running session to shut down. -nightcrow stop -``` +## Security -`-d` gives the session its own process group, so closing the terminal you -started it from does not stop it; what it would have printed goes to -`~/.nightcrow/daemon.out`. Under a service manager, start it *without* `-d` — -backgrounding is what the manager does itself. - -The session prints the address of its browser view (`http://127.0.0.1:8091/` by -default) and the socket an attaching terminal uses. Both show the same -repositories — open one with ` o` in the TUI or the folder picker in the -browser, and it appears in the other. - -The leader (prefix) key is `Ctrl+F` by default. Press it, then one key: -` o` opens a repo, ` t` a terminal pane, ` l` the commit log, -` b` the file tree, ` f` fullscreen, ` q` detaches. Every other -key — including Ctrl chords — passes straight through to the focused terminal, so -the CLI running there receives them unchanged. - -| Key | Action | -|-----|--------| -| ` t` | New terminal pane | -| ` w` | Close pane | -| ` l` | Toggle commit log | -| ` b` | Toggle file tree | -| ` f` | Toggle fullscreen | -| ` s` | Swap pane prompt | -| ` z` | Claim pane sizing | -| ` c` | Cancel recovery | -| ` o` | Open project | -| ` x` | Close project | -| ` p` | Cycle theme | -| ` u` | Reload config | -| ` r` | Redraw | -| ` q` | Detach (the session keeps running) | -| ` 1` | Focus file list | -| ` 2` | Focus diff viewer | -| ` 3`…` 9` | Switch to pane 0–6 | -| ` 0` | Switch to pane 7 | - -## What it does - -- **Up to 10 repositories at once**, each a project tab with its own git views, - snapshot worker, terminal panes, and a blinking dot when background terminal - activity needs attention. → [Projects](docs/projects.md) -- **Three views** over each repo — changed files with a syntax-highlighted diff, - a tig-like commit log, and a read-only file tree you can browse and search. - → [Views](docs/views.md) -- **A split-grid terminal panel** where every visible pane renders at once, with - scrollback, OSC title capture, and mouse routing. → - [Keyboard and mouse](docs/keybindings.md) -- **A browser surface** serving the same git data and the same terminals, with a - phone layout and an on-screen key bar. → [Web viewer](docs/web-viewer.md) -- **Recent-activity highlighting** — files touched in the last few seconds are - accented, so you see what an agent just changed. - → [Session state](docs/session-state.md) -- **Session persistence** — tabs, selection, scroll, and view mode come back on - the next launch. Nothing is written inside your repositories. -- **Plugins** for behaviour that must know a specific CLI; the bundled - `nightcrow-recovery` waits out a provider's usage limit and re-opens the - session. → [Plugins](docs/plugins.md) - -Configure it in `~/.nightcrow/config.toml` (`nightcrow init` writes a commented -starter) — see [Configuration](docs/configuration.md). - -> **Security.** The web viewer serves repository contents *and* interactive -> terminals, so an authenticated session is equivalent to shell access. It binds -> to loopback and speaks plain HTTP with no built-in TLS. For remote access, -> tunnel it over SSH or put it behind a TLS reverse proxy. +The authenticated web viewer exposes repository contents and interactive terminals, which is equivalent to shell access. It binds to loopback and uses plain HTTP by default. Do not expose the port directly on a network; use an SSH tunnel or a TLS reverse proxy, and protect the password/configuration files. ## Documentation -Full docs are in [`docs/`](docs/README.md) — usage guides per surface, the -[architecture](docs/architecture.md), and the -[design-decision history](docs/decisions.md). - -## License +The [documentation index](docs/README.md) routes to user guides and the separate [architecture](docs/architecture.md) and [design decisions](docs/decisions.md) references. Apache License 2.0. See [LICENSE](LICENSE). diff --git a/config.example.toml b/config.example.toml index 143c0b8f..948af3be 100644 --- a/config.example.toml +++ b/config.example.toml @@ -121,8 +121,8 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f # your plain shells, stays untouched: # # [[startup_command]] -# name = "Claude" -# command = "claude" +# name = "Codex" +# command = "codex" # plugin = "recovery" # optional; omitted means no plugin sees this pane # External plugin processes. nightcrow launches each enabled entry and speaks @@ -151,10 +151,10 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f # # watch_on_signal covers the pane you did not configure: you opened a shell with # t and started a coding CLI in it by hand. nightcrow gives every pane a -# random token and puts it in that pane's environment only, so the CLI's own hook -# — a child of the CLI, a grandchild of the pane — can quote it back to the -# plugin. The plugin then asks nightcrow for "the pane this token names", and -# nightcrow checks the token really is one of its panes before handing it over. +# random token and puts it in that pane's environment only, so a process inside +# the pane can quote it back to the plugin. The plugin then asks nightcrow for +# "the pane this token names", and nightcrow checks the token really is one of +# its panes before handing it over. # # What you are turning on, precisely: a pane becomes plugin-visible once # something running inside it has spoken to that plugin. A plain shell never @@ -174,14 +174,14 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f # args = [] # passed to the plugin verbatim # enabled = false # off by default; set true to actually run it # watch_on_signal = false # off by default; see the paragraphs above -# allowed_resume_flags = ["--resume", "resume", "--session"] +# allowed_resume_flags = ["resume", "--session"] # # empty by default, which refuses relaunch # # arguments. Entries are flags/subcommands. -# # These three are what the bundled recovery -# # plugin needs for Claude/Codex/OpenCode. +# # The bundled recovery needs "resume" and +# # "--session" for Codex/OpenCode. # # [plugin.env] -# NIGHTCROW_RECOVERY_LOG = "info" +# PLUGIN_LOG = "info" # The browser surface: renders git data as a real web page and serves the # session's terminals. Always on — it is part of the session, not an add-on — diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 3f33f620..06422d48 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,7 +1,7 @@ -# docs 범위 지침 +# `docs/` scope -`docs/` 아래 문서의 공통 작성 규칙과 문서화 범위는 [`.agents/rules/docs.md`](../.agents/rules/docs.md)를 권위 문서로 따른다. +`docs/` 아래 문서의 공통 작성 규칙은 [`.agents/rules/docs.md`](../.agents/rules/docs.md)를 따른다. -설계 근거, 계층 구조, 모듈 책임, 핵심 결정은 [`architecture.md`](architecture.md)에 두고, 설치·실행과 기능 사용 절차는 루트 [`README.md`](../README.md) 또는 해당 기능 문서로 라우팅한다. 설계 내용을 사용자 문서에 복제하지 말고 필요한 경우 기준 문서로 링크한다. +설계 근거·계층·모듈 책임·핵심 결정은 [`architecture.md`](architecture.md)와 그 상세 문서에 둔다. 설치·실행의 요약은 루트 [`README.md`](../README.md)에, 기능별 절차는 해당 문서에 둔다. 같은 설명을 여러 문서에 복제하지 말고 기준 문서로 링크한다. -Markdown prose에는 고정 열 기준 hard-wrap을 사용하지 않는다. 문단은 논리적으로 한 줄로 유지하고 표·목록·코드처럼 구조상 필요한 개행만 둔다. +Markdown prose는 고정 열 기준으로 hard-wrap하지 않는다. 문단은 논리적으로 한 줄로 유지하고 표·목록·코드처럼 구조상 필요한 개행만 둔다. diff --git a/docs/README.md b/docs/README.md index 5b2b7cf7..d0473d13 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,30 +1,22 @@ # nightcrow documentation -The [top-level README](../README.md) is the tour: what nightcrow is, how to install it, and enough usage to get a session up. Everything past that lives here, one page per surface. +Start with the [top-level README](../README.md) for the five-minute install and first session. Use [Getting started](getting-started.md) for the complete install, run, update, and development workflow. -## Using nightcrow +## User guides -| Page | What it covers | +| Guide | Scope | | --- | --- | -| [Getting started](getting-started.md) | Install, starting and stopping a session, attaching, startup panes | -| [Projects](projects.md) | Repository tabs, the empty state, per-project scope | -| [Views](views.md) | Status, commit log, and tree views; the notice row; the repo dialog and its directory browser | -| [Keyboard and mouse](keybindings.md) | The leader key, every binding, mouse routing | -| [Session state](session-state.md) | Recent-activity highlighting, what persists across restarts and who owns it | -| [Web viewer](web-viewer.md) | The browser surface, phone layout, authentication, frontend development | -| [Plugins](plugins.md) | The plugin boundary and the bundled `nightcrow-recovery` | -| [Configuration](configuration.md) | Every `config.toml` table, and which ones reload without a restart | +| [Projects](projects.md) | Repository tabs and per-project terminal limits | +| [Views](views.md) | Status, commit log, tree, notices, and repository picker | +| [Keyboard and mouse](keybindings.md) | Leader commands, navigation, terminal input, and mouse routing | +| [Session state](session-state.md) | Recent-activity indicator and files written between runs | +| [Web viewer](web-viewer.md) | Browser access, cloning, mobile layout, and security | +| [Configuration](configuration.md) | `~/.nightcrow/config.toml`, defaults, validation, and reload scope | +| [Plugins](plugins.md) | Plugin installation, opt-in, and bundled recovery plugin | -## Working on nightcrow +Each guide is authoritative for its surface. Cross-links point back here or to the guide that owns a shared rule; design rationale and module boundaries remain in [Architecture](architecture.md), and historical decisions remain in [Design decisions](decisions.md). -| Page | What it covers | -| --- | --- | -| [Architecture](architecture.md) | Index: overview, layout, module map, stack — and links into the detail pages below | -| [· Session](architecture/session.md) | Daemon ↔ client split, `TerminalBackend`, PTY size ownership, config reload | -| [· Git views](architecture/git-views.md) | Diff pipeline, gutter and wrapping, tree navigator, commit-log decoration | -| [· Terminal](architecture/terminal.md) | Split-view pane grid, emulation layer, scroll and mouse routing | -| [· UI](architecture/ui.md) | Keyboard routing, the `Workspace`/`App` project boundary, notice row | -| [· Plugin host](architecture/plugin-host.md) | The trust boundary and the recovery surface | -| [· Web layer](architecture/web.md) | Shared HTTP/SSE primitives, the viewer, the frontend | -| [Design decisions](decisions.md) | Why it went this way — rejected alternatives and where implementation diverged from plan | -| [AGENTS.md](../AGENTS.md) | Contribution workflow and repository conventions | +## Development references + +- [Getting started → Building and testing](getting-started.md#building-and-testing) contains the repository verification gates. +- [Architecture](architecture.md) documents system boundaries and implementation responsibilities. diff --git a/docs/architecture.md b/docs/architecture.md index 4ebdea79..0a81bfa7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,213 +1,60 @@ # nightcrow Architecture -이 문서는 색인이다. 전체 그림과 불변식만 담고, 각 영역의 상세 설계는 `docs/architecture/` 아래 하위 문서로 나뉘어 있다 — 맨 아래 [Detailed design](#detailed-design) 표를 보라. +이 문서는 현재 설계의 색인이다. 전체 그림과 여러 영역에 걸친 불변식만 여기 두고, 영역별 계약은 [`docs/architecture/`](architecture/) 아래 문서에 둔다. 설계의 선택 이유와 채택하지 않은 대안은 [`decisions.md`](decisions.md)에 한 번만 기록한다. ## Overview -nightcrow는 **세션 데몬 하나 + 프론트엔드 N개** 구조의 agent-adjacent Rust 애플리케이션이다. `nightcrow`가 세션(저장소 집합과 터미널)을 소유하고, 터미널에서 `nightcrow attach`로, 브라우저에서 웹으로 같은 세션에 붙는다. 클라이언트가 나가도 세션은 산다. 화면은 상단 패널에서 git diff를 실시간 추적하고, 하단 패널에서 임의의 프로세스(주로 LLM CLI나 빌드/테스트 러너)를 동시에 실행한다. +nightcrow는 하나의 세션 데몬과 여러 프론트엔드로 이루어진 agent-adjacent Rust 애플리케이션이다. 인자 없이 `nightcrow`를 실행하면 데몬과 browser viewer가 함께 시작되고, TUI는 별도 `nightcrow attach`로 같은 세션에 붙는다. 데몬은 저장소 집합·터미널 pane·공유 preference를 소유하고 두 클라이언트는 각자 화면을 렌더한다. 클라이언트가 사라져도 데몬과 pane은 계속 살아 있어 재접속할 수 있다. 두 표면은 같은 세션 capability를 사용하되, 화면 기하·수명·입력 모델이 다른 부분은 각 상세 문서에 명시한다. -nightcrow 자체는 AI에 대한 ontology를 갖지 않는다 — agent든 사람이든 동일한 PTY와 파일 mtime을 본다. provider를 아는 동작(예: rate limit이 풀릴 때까지 기다렸다 세션을 재개하는 것)이 필요하면 코어가 아니라 **plugin**이 갖는다. 코어는 pane을 외부 프로세스에 보여주고 그 프로세스가 요청한 것을 검증할 뿐, 어떤 CLI가 무엇을 출력하는지는 끝까지 모른다 — [plugin-host.md](architecture/plugin-host.md) 참고. +상단은 저장소의 status/diff, commit log, read-only tree를 보여주고 하단은 여러 PTY를 동시에 보여준다. TUI와 브라우저는 같은 저장소·터미널 상태를 읽지만 기하, 커서·스크롤, 검색과 같은 표시 상태는 각자 가진다. 코어는 AI provider를 해석하지 않으며 provider별 동작은 프로세스 경계의 plugin에 둔다. -**대상 사용자**: 터미널 중심으로 작업하면서, 옆 패널의 LLM CLI(Claude Code, Codex, aider 등)나 빌드/테스트 러너가 만든 코드 변경을 실시간으로 따라잡고 싶은 개발자. - -**핵심 기능**: 멀티 프로젝트 탭(최대 10개 저장소), 변경 파일 리스트 + git diff 뷰어(문법 하이라이팅), commit log 뷰, read-only 파일 트리 내비게이터(라이브 워치 + 재귀 파일명 검색 + 마크다운·HTML 렌더 뷰), split-view 멀티 PTY 패널, mtime 기반 hot-file 강조 + idle auto-follow, OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠 라우팅, 클릭 가능한 힌트 바). - -**웹 표면**: 같은 git 데이터를 DOM으로 렌더하고 세션의 터미널을 서빙하는 웹 뷰어(`[web_viewer]`). 세션의 일부라 항상 뜨며, attach 소켓과 인증 방식이 다르다 — 소켓은 파일 권한, 웹은 Argon2 로그인. - -**두 표면은 기능적으로 동일하다**. viewer와 attached TUI는 같은 세션에 붙은 클라이언트이므로, 한쪽에만 있는 기능은 "이 도구가 무엇을 할 수 있는가"를 어느 화면을 보느냐에 따라 달라지게 만든다. 다만 **구현 방식은 갈라질 수 있고, 갈라지는 것이 자연스럽지 않으면 구현하지 않을 수도 있다** — 터미널과 브라우저는 입력·기하·수명이 다르기 때문이다. 갈라질 때는 무엇을 포기했는지 남긴다. 예를 들어 accent와 pane zoom은 세션이 공유하지만 `upper_pct`는 공유하지 않는다: 퍼센트가 터미널과 브라우저에서 다른 크기를 뜻하기 때문이며, 이유는 `src/session/prefs/`에 적혀 있다. 반대로 한쪽만 되는 것이 결함인 경우가 더 많다 — 삭제된 파일의 diff는 TUI에서 줄곧 됐고 viewer만 400이었다. - -## Layout - -``` -│ F1 repo-a F2 repo-b +2 │ ← project tab row -├──────────────────────┬──────────────────────┤ -│ File List (20~25%) │ Diff Viewer (75~80%) │ ← upper panel -├──────────────────────┴──────────────────────┤ -│ ^F 3 pane-a ^F 4 pane-b +2 (tab bar) │ -├────────────────────┬────────────────────────┤ -│ Pane A (active) │ Pane B │ ← split-view grid: every -├────────────────────┼────────────────────────┤ visible pane renders at -│ Pane C │ Pane D │ once, not one-at-a-time -├────────────────────┴────────────────────────┤ -│ ~/path/to/repo branch ↑N ↓M │ ← notice row (repo identity, -│ hint bar (focused-pane shortcuts) │ or a notice covering it) -└─────────────────────────────────────────────┘ -``` - -크롬 행 불변식 셋: - -- **네 행 분할은 `ui::chrome::chrome_rows` 한 곳에서만 계산된다.** `draw`와 세 개의 geometry helper(PTY 사이저, upper-panel/hint-bar hit test)가 정확히 같은 셀에 떨어져야 하므로, 손으로 복사된 분할이 어긋나면 터미널 크기가 틀어지거나 모든 마우스 클릭이 한 행씩 밀린다. -- **프로젝트 탭 행은 탭 개수와 무관하게 항상 존재한다.** 행이 생겼다 사라지면 프로젝트를 열고 닫을 때마다 모든 PTY가 resize되는데, notice row를 별도 행이 아닌 오버레이로 둔 것과 같은 이유다. -- **탭 행과 notice row는 `draw`의 레이아웃 분기 이전에 렌더된다.** fullscreen에서 탭이 사라지면 사용자가 어느 프로젝트에 있는지 알 수 없어지므로, 분기마다 중복 렌더하는 대신 구조로 보장한다. - -하단 패널은 탭 전환이 아니라 balanced grid로 *보이는* 모든 pane을 동시에 그린다 — [terminal.md](architecture/terminal.md) 참고. - -## Module Structure - -모든 소스 파일은 300줄 이하(LOC 규칙, `.agents/rules/guardrails.md` 참고). 테스트는 `#[cfg(test)] mod tests;`로 별도 파일/디렉터리에 분리한다(아래 트리에서는 생략). - -``` -src/ -├── main.rs # entry point: dispatch to daemon / attach / serve / init -├── cli.rs, cli/ # Cli/Commands + attach/daemon/init/stop/plugin command handlers -├── test_util.rs # #[cfg(test)] git fixture helpers shared across modules -├── persistence.rs # typed JSON reads + same-directory atomic replacement -├── daemon/ # the session socket -│ ├── socket.rs, lock.rs, detach.rs # 0600 socket + stale handling, flock single- -│ │ # instance lock, backgrounding by re-exec (not fork) -│ ├── frame.rs, protocol.rs, wire.rs # framing (control vs terminal output), -│ │ # Client/ServerMessage JSON, locked write + read-side sort -│ ├── serve.rs, client.rs, clients.rs, requests.rs # accept loop, the attaching side, -│ │ # the attached set + what each has been told, request handling -│ ├── watch.rs # the ONLY sender of the repo set (see session.md) -│ └── terminals.rs, terminal_link.rs # subscribe a client to every repo's hub; -│ # demultiplex terminal traffic per repository -├── application/ # attached TUI orchestration -│ ├── attach.rs, session_link.rs # `nightcrow attach`; the client's half of the -│ │ # daemon-owned tab list -│ ├── terminal_guard.rs # raw mode + alternate screen, restored on the way out -│ ├── bootstrap.rs, event_loop.rs, splash.rs # App construction + startup commands, -│ │ # main_loop (poll/render/input drain), first-run overlay -│ └── input/ # dispatch, ViewMode handlers, prefix follow-up, -│ # mouse, paste, repo-dialog keys -├── platform/ # OS-adjacent services shared by domain layers: -│ # logging.rs (file logger, rotation + retention), paths.rs -│ # (tilde expansion), signals.rs (SIGINT/SIGTERM shutdown), -│ # threading.rs (try_timed_join) -├── app.rs, app/ # App coordinates terminal/focus/fullscreen/notice/interaction with -│ # GitViewManager; RepositoryView owns status/log/tree/diff, -│ # auto-follow, tree watch state, and pending selection; -│ # GitViewManager owns repo identity/cache, snapshot/load workers, -│ # commit-log controller, tracking, branch, and ref decorations -├── config.rs, config/ # config.toml root + layout/theme/input, log, panels, -│ # plugin ([[plugin]]), web (WebViewerConfig, password bootstrap) -├── workspace/ -│ ├── mod.rs # Workspace: open projects (Vec) + active index -│ ├── accent.rs # the session's accent, adopted from the daemon -│ ├── repo_input.rs, repo_picker.rs # o 모달 상태; 필드 ↔ 브라우저 전환 -│ ├── path_complete/ # Tab 경로 완성 (read_dir 한 단계, 디렉터리만) -│ ├── path_tree/ # ↓ 디렉터리 브라우저 상태 (평면 row 리스트) -│ └── persistence.rs # workspace + per-repo state (~/.nightcrow/workspace.json) -├── runtime/ -│ ├── snapshot.rs, snapshot/ # SnapshotChannel + the reader thread (worker.rs) -│ ├── snapshot_watch.rs # recursive worktree watch: read on change, not on a timer -│ ├── tree_watch.rs # notify-based watcher for expanded tree directories -│ ├── emulator/ # PaneEmulator (alacritty_terminal), PaneModes, ScreenView -│ └── terminal/ # TerminalState: state, scroll, lifecycle, input, -│ # session_panes (close/reorder), recovery, escape strip -├── ui/ -│ ├── mod.rs # root layout: draw, draw_empty, pub use re-exports -│ ├── chrome.rs # ChromeRows, chrome_rows, main_content_constraints -│ ├── helpers.rs # shared widget/style helpers (status_color, char_offset, …) -│ ├── notice.rs # notice row + repo header rendering -│ ├── repo_dialog.rs # repo dialog rows: input line, key legend / reports -│ ├── hint_text.rs, hint_bar.rs # hint literals; render, segment_click, hint_click_at -│ ├── hit_test.rs # pane_at, tab_click_at, upper_panel_at, terminal_content_areas -│ ├── status_view.rs, log_view/, tree_view/ # per-ViewMode state (filter/search cache, -│ │ # commits + drill-down, child cache + expanded set) -│ ├── file_list.rs, commit_list/, tree_list.rs # the three upper-left row renderers -│ ├── path_tree.rs, file_view.rs, search.rs, splash.rs, wall_clock.rs # repo-dialog -│ │ # browser, file preview state, SearchQuery newtype, first-run -│ │ # overlay, unix epoch → HH:MM without a date crate -│ ├── diff_pane/, diff_viewer/ # DiffPane state (hunks/scroll/search/split); the -│ │ # upper-right widget, gutter, split view, file preview -│ └── terminal_tab/, project_tab/ # pane grid + tab bar + recovery markers; -│ # project tab row rendering + click targets -├── backend/ -│ ├── mod.rs # TerminalBackend trait + BackendEvent -│ ├── identity.rs # PaneToken / PaneGeneration: a pane's name outside this process -│ ├── slot.rs # per-slot bookkeeping (launch, idle clock) + resume arg validation -│ ├── pty.rs, pty_spawn.rs # PtyBackend (owns its children); spawn path + env injection -│ └── hub.rs # HubBackend: the same trait over the daemon socket; owns nothing -├── plugin/ # provider-agnostic plugin host (mod.rs states the trust posture) -│ ├── protocol.rs # NDJSON wire contract (events out, commands in) -│ ├── host.rs, host_pump.rs # one long-lived child per plugin; pumps + capped reader -│ ├── guard.rs # the trust boundary: PluginCommand -> Approved | Refused -│ ├── guard_budget.rs # per-slot rate ceilings, keyed by PaneToken -│ ├── guard_watch.rs # the one rule that can widen what a plugin sees -│ ├── guard_refusal.rs, guard_text.rs # refusal reasons; bounding plugin-supplied text -│ └── registry.rs, registry/ # ~/.nightcrow/plugins: config snippets, executable -│ # resolution, atomic install/list/remove storage -├── git/ -│ ├── diff.rs, diff/ # types, snapshot loader, diff/commit loaders, conflated load worker, -│ │ # commit_log, refs -│ ├── clone.rs, clone/ # delegate `git clone` to the binary; URL scheme whitelist -│ ├── path/ # repo-relative path validation before any filesystem read -│ └── tree/ # lazy read-only directory listing (gitignore filter, symlink guard) -├── input/ # Action enum (mod.rs), routing.rs (map_key, prefix_action, -│ # prefix_action_fullscreen, vim j/k), encode.rs (encode_key, -│ # encode_wheel/button/arrow, CSI/SS3 helpers) -├── session/ # daemon-owned, transport-neutral shared session core -│ ├── state.rs, operations.rs, reload.rs # ownership, mutations, live config reload -│ ├── catalog/ # pure membership + live runtime reconciliation, -│ │ # opaque repo ids, ordering, config tables -│ ├── runtime/ # SnapshotChannel drain + conflated status fan-out -│ ├── terminal/ # TerminalHub, PtyBackend ownership, shared terminal frames -│ ├── size_owner.rs # which client screen the session PTYs are fitted to -│ └── prefs/ # persisted accent, active repo, browser arrangements -└── web/ # browser transport - ├── common/ # HTTP primitives: auth, parsing, SSE, connection slots - └── viewer/ # native web viewer ([web_viewer] / `serve`) - ├── limits.rs # HTTP/git serialization ceilings - ├── dto/ # whitelisted browser wire types + PROTOCOL_VERSION - ├── status_payload.rs # status encoder injected into the session runtime - ├── clone_jobs.rs, clone_jobs/ # in-flight clone tracking - ├── highlight.rs, assets.rs # syntect spans; embedded frontend bundle - └── server/ # HTTP-only state; handlers split by HTTP/repo/SSE/terminal +```text +filesystem/git ──> per-repository runtime ──> session daemon + ├── local attach TUI + └── authenticated web viewer ``` -## Stack +## Ownership and data flow -| 용도 | 크레이트 | -|------|---------| -| TUI 렌더링 | ratatui 0.30 + crossterm 0.29 | -| Git diff | git2 0.21 (vendored libgit2/openssl) | -| 문법 하이라이팅 | syntect 5.3 + two-face 0.5 (문법 정의 확장) | -| PTY 관리 | portable-pty 0.9 | -| 터미널 에뮬레이션 | alacritty_terminal 0.26 | -| 파일시스템 감시 | notify 8.2 + notify-debouncer-mini 0.7 | -| 파일 로깅 | tracing + tracing-subscriber + tracing-appender | -| 설정 파싱 | toml 1 + serde | -| 형식 보존 설정 편집 | toml_edit 0.25 | -| 세션 JSON 저장 | serde_json | -| 임시 파일 기반 교체 | tempfile 3 | -| CLI args | clap 4 (derive) | -| 프로세스 제어 | libc (`flock`) + signal-hook 0.4 (SIGTERM/SIGINT) | -| 웹 서버 | tungstenite 0.30 (sync WS) + argon2 0.5 + getrandom 0.4 | -| 웹 뷰어 번들 임베드 | rust-embed 8.12 (`viewer-ui/dist`) | -| 웹 뷰어 프론트엔드 | React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6, 마크다운은 react-markdown 10(+remark-gfm, rehype-highlight), 테스트는 vitest 4 | +| 경계 | 소유하는 것 | 외부에 노출하는 방식 | +| --- | --- | --- | +| `session/` | catalog, repo runtime, terminal hub/PTY, active repo·order·accent, PTY 크기 소유권 | transport-neutral operation과 runtime event | +| `application/` + `app/` | TUI의 `Workspace`/`App`, 포커스·기하·검색·스크롤·로컬 view state | daemon socket 요청과 ratatui 렌더 | +| `web/` + `viewer-ui/` | HTTP/WebSocket 인증·wire·브라우저 기하와 viewer preference | JSON/SSE/terminal binary 및 DOM 렌더 | +| `plugin/` | provider별 감지·복구 프로세스 | 제한된 NDJSON event/command | -`toml_edit`는 생성한 웹 비밀번호만 바꾸면서 기존 TOML의 주석·공백·키 순서를 보존하려고 쓴다. `toml`로 전체 문서를 다시 직렬화하거나 문자열을 직접 고치는 대안은 서식을 잃거나 동등한 table 표현을 빠뜨릴 수 있어 채택하지 않았다. `tempfile`은 상태와 설정을 대상 디렉터리의 충돌 방지 임시 파일에 쓴 뒤 `persist`로 교체하는 데 쓴다. 같은 디렉터리를 쓰면 부분 기록과 파일시스템 간 이동 위험을 줄이지만, 교체의 원자성은 파일시스템과 플랫폼에 달려 있다. `std::fs`만 쓰는 대안은 고유 이름의 배타적 생성, 실패 시 정리, Windows 교체 동작을 직접 구현해야 하므로 채택하지 않았다. +저장소 catalog는 membership(경로·순서·숨김·opaque id)과 runtime(worker·terminal hub)을 분리한다. 변경은 하나의 catalog transaction에서 membership을 계산하고 runtime을 reconcile한다. 같은 경로의 entry는 유지해 watcher·SSE·hub를 불필요하게 교체하지 않으며, retired worker의 종료는 catalog lock을 놓은 뒤 수행한다. -PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다. 초기에는 tmux control-mode 백엔드(`TmuxBackend`)도 병행 지원했으나, 중첩 TUI 키보드 라우팅 문제를 leader(prefix) 모델로 해결하면서 tmux 의존성 없이 `PtyBackend`만으로 충분해져 제거했다. +status는 저장소별 snapshot worker가 파일시스템 변화에 반응해 읽고, 구독자가 없으면 읽거나 감시하지 않는다. 구독자가 없는 `/api/status`의 on-demand 요청만 한 번 읽을 수 있다. status payload는 최신 상태만 의미하므로 conflate할 수 있지만 terminal byte는 순서가 있는 스트림이라 버릴 수 없다. git diff/file/log 선택 로드는 `git2::Repository`를 소유하는 수명 긴 worker에서 lane별로 합치고 `(repository, generation)`이 현재 의도와 다르면 늦은 결과를 버린다. tree는 필요한 directory만 UI 경계에서 lazy-read한다. -## Critical Risk +터미널 hub는 PTY를 소유하고 raw byte와 lifecycle/control event를 클라이언트에 전달한다. 각 클라이언트는 같은 byte를 자체 emulator에 적용하며, hub emulator는 재접속에 필요한 현재 mode/title/screen과 `screen + since` replay를 만든다. attach와 WebSocket 모두 bounded queue를 가지며 terminal queue overflow는 연결을 끊어 손상된 stream을 계속 그리지 않는다. -**중첩 TUI 키보드 라우팅**: Claude Code, Codex 등 LLM CLI는 자체 TUI를 가진다. Ratatui 레이어와 내부 TUI 간 키보드 이벤트 충돌은 leader(prefix) 모델로 회피한다. 앱 전역 명령은 leader(기본 `Ctrl+F`) 뒤의 한 키로만 실행되고, 그 외 모든 키(단독 Ctrl 포함)는 raw key 그대로 PTY로 전달된다(`input::encode_key`). 이로써 `Ctrl+W`/`Ctrl+L` 등 프롬프트 편집 Ctrl 키가 nightcrow에 가로채이지 않고 내부 프로그램에 도달한다. leader와 충돌하지 않는 예약키는 modifier 필수(Shift+arrow/PgUp/PgDn) 또는 F-key(F1–F10)로 제한해, 터미널마다 일관되게 식별되고 프롬프트 텍스트와 섞이지 않는다. 상세는 [ui.md](architecture/ui.md#keyboard-routing). +## Shared state and client state -## Development History +- 세션 전체에 하나인 값은 열린 저장소와 순서, active repo, pane 집합·내용·순서·확정된 크기, accent다. active repo와 accent는 TUI와 브라우저가 같은 값을 따른다. +- 브라우저끼리만 공유하는 값은 `viewer.json`의 sidebar width, `upper_pct`, project별 마지막 view와 maximize arrangement다. 화면 크기 의미가 달라 TUI와는 공유하지 않는다. +- 커서·스크롤·포커스·fullscreen·검색 입력과 TUI의 `Workspace` view state는 클라이언트별이다. 숨은 project의 terminal attention/read 상태도 client-local이라 한 표면의 활동이 다른 표면의 읽음 상태를 지우지 않는다. TUI의 workspace 파일과 viewer preference 파일은 서로 덮어쓰지 않는다. +- PTY 크기는 세션 하나의 owner가 결정한다. 새 viewer의 명시적 도착 또는 `claim`만 owner를 바꾸고, 떠난 owner의 해제는 2초 grace 뒤 남은 viewer로 넘긴다. 비소유자의 resize는 버리고 실제 적용된 `Resized`만 모두에게 반영한다. -- 프로젝트 골격: 상단 파일 리스트 + diff 뷰어, git2 기반 변경 파일/diff 파이프라인 -- 멀티 터미널: `TerminalBackend` trait 도입, `TmuxBackend` → `PtyBackend` 단일화, 중첩 TUI 키보드 라우팅을 leader 모델로 정리 -- 릴리스 준비: `config.toml` 설정 시스템, 파일 로깅(rotation + retention), clippy/audit clean, CI -- 터미널 확장: split-view grid, fullscreen 3-state 사이클, pane swap, layout-aware jump digit, 프로그램 모드 기반 scroll/mouse routing, 클릭 가능한 힌트 바·탭 바 -- 터미널 에뮬레이터 교체: vt100 → alacritty_terminal(쿼리 응답, resize reflow, wide-char 크래시) -- 멀티 프로젝트: 저장소 10개를 탭으로(F1–F10), 세션을 `~/.nightcrow/workspace.json`으로 통합 -- 웹 뷰어(`[web_viewer]` / `nightcrow serve`): 같은 git 데이터를 DOM으로 렌더하는 두 번째 프론트엔드. 이후 commit 드릴다운, diff split, 마크다운·HTML 렌더 뷰, hot-file 강조, 서버 저장 preference, 클론, 폰 레이아웃으로 확장 -- 세션 데몬 전환: 데몬이 세션을 소유하고 TUI·브라우저가 클라이언트가 됐다. 화면을 반사하던 `[web_mirror]` 서버는 반사할 대상이 없어져 제거했다 — 배경은 [decisions.md](decisions.md) +## Cross-cutting invariants -## Future Refactor Notes - -- 저장소별 상태는 `GitViewManager`와 그 안의 `RepositoryView`로 분리됐다. `App`은 terminal/focus/fullscreen/notice/interaction을 소유하고 명시적 façade로 UI·입력 계층에 저장소 상태를 제공한다. 이후 분리는 manager 내부 동작이 독립 수명이나 동시성 경계를 실제로 얻을 때만 진행한다. -- diff/file/commit/ref 로드는 lane별 conflation과 generation guard를 갖춘 `GitLoadWorker`로 비동기화돼 있다. 추가 최적화는 측정 결과가 필요할 때 watcher event debouncing이나 lane별 비용을 대상으로 한다. +- **기하의 단일 출처**: 네 chrome 행은 `ui::chrome::chrome_rows`, visible pane cell은 `ui::terminal_tab::visible_pane_cells`만 계산한다. 렌더·resize·hit-test가 별도 산술을 갖지 않는다. 프로젝트 tab 행과 notice 행은 항상 존재해 PTY가 행 삽입/삭제로 resize되지 않는다. +- **입력 보호**: 기본 leader(`Ctrl+F`, 설정 가능) 뒤에만 앱 명령을 두고, 그 밖의 일반 키·단독 Ctrl은 active pane으로 그대로 보낸다. 앱이 합성하는 scroll/mouse report도 프로그램이 해당 mode를 켠 경우에만 보낸다. +- **순서와 generation**: pane 생성·종료·resize·reorder는 backend/session event가 확정한다. daemon의 repository set은 watcher 한 곳만 전송하며, terminal output은 repo별 FIFO를 유지한다. 비동기 git 결과는 generation guard를 통과한 것만 적용한다. +- **경로 경계**: worktree 파일을 열 때는 `git::path::resolve_in_workdir`를, git object/pathspec만 다룰 때는 `validate_commit_path`를 사용한다. traversal·절대 경로·NUL·`.git` 변형을 거부하며 worktree 파일은 중간 component의 symlink도 따르지 않는다. 웹 route가 검증을 중복 구현하지 않고 공통 handler를 통과한다. +- **자원 상한과 오류**: frame, terminal queue, PTY/pane, 웹 연결·응답·목록·diff·검색에는 명시적 상한이 있다. 잘린 결과는 `truncated` 등으로 표시하고, malformed/truncated input과 외부 호출 실패는 성공처럼 기록하지 않는다. +- **플랫폼 seam**: 경로·로그·signal·thread helper는 `platform/`, daemon socket type은 `daemon/transport.rs`에 모은다. Unix 전용 API와 Windows ConPTY 차이는 seam 뒤에 두고, 대응물이 없는 차이는 해당 상세 문서에 남긴다. +- **보안 경계**: attach socket은 파일 권한, web은 Argon2 password와 server-side session cookie를 사용한다. 웹은 Host → Origin → static bundle → authentication → repository lookup → path gate 순서로 처리하며, plugin command는 shape 검사와 별개로 `Guard`의 권한 판정을 거친다. ## Detailed design -| 문서 | 내용 | -|---|---| -| [architecture/session.md](architecture/session.md) | `TerminalBackend` trait, 데몬↔클라이언트 세션 공유, PTY 크기 소유권, 변화 기반 status 읽기, 스크롤백·재접속, config reload, worker join 정책 | -| [architecture/git-views.md](architecture/git-views.md) | git diff 파이프라인과 경로 검증, 줄 번호 gutter, 줄바꿈, status filter cache, 파일 트리 내비게이터, HEAD 변경 감지, commit log decoration | -| [architecture/terminal.md](architecture/terminal.md) | split-view pane grid와 sizing 불변식, fullscreen 사이클, VT 에뮬레이션 계층, scroll sink 판정, 마우스 라우팅 | -| [architecture/ui.md](architecture/ui.md) | leader 키 라우팅, `Workspace`/`App` 프로젝트 경계와 repo 다이얼로그, polling·세션·자원 측정치, notice row | -| [architecture/plugin-host.md](architecture/plugin-host.md) | provider-agnostic host, opt-in과 토큰 증명, `guard.rs` 신뢰 경계, recovery plugin, recovery surface | -| [architecture/web.md](architecture/web.md) | 공용 웹 계층, 뷰어 서버의 요청 처리 순서, wire fixture, commit log 페이지네이션, 프론트엔드 상태·렌더링, 클론, 잔여 위험 | +| 문서 | 현재 계약 | +| --- | --- | +| [session.md](architecture/session.md) | `TerminalBackend`, daemon/client ownership, catalog·watcher, PTY size owner, replay/backpressure, config reload, worker 종료 | +| [git-views.md](architecture/git-views.md) | diff/file/log/tree pipeline, path gate, line rendering, status cache, HEAD/ref 갱신 | +| [terminal.md](architecture/terminal.md) | pane grid와 sizing, VT emulator, scroll/mouse routing | +| [ui.md](architecture/ui.md) | leader routing, `Workspace`/`App`, repo dialog, dirty redraw, chrome/notice | +| [plugin-host.md](architecture/plugin-host.md) | process/NDJSON boundary, opt-in/token guard, relaunch/recovery surface | +| [web.md](architecture/web.md) | web auth/HTTP/SSE/WS, route/path gates, wire fixture, browser state, clone | + +← [Documentation index](README.md) diff --git a/docs/architecture/git-views.md b/docs/architecture/git-views.md index 116142d0..fd597a39 100644 --- a/docs/architecture/git-views.md +++ b/docs/architecture/git-views.md @@ -1,63 +1,30 @@ # Git Views -상단 패널이 보여주는 세 가지 뷰 — status(변경 파일 + diff), log(커밋 목록 + 드릴다운), tree(read-only 파일 트리) — 를 떠받치는 데이터 파이프라인과 렌더 규칙을 다룬다. 세 뷰 모두 같은 `git2::Repository` 캐시와 같은 경로 검증기를 지나며, 우측 pane(diff/file view)은 세 뷰가 공유한다. +상단 패널의 status/diff, commit log, read-only tree가 공유하는 git 데이터 계약을 다룬다. 프로젝트 하나의 `GitViewManager`가 repository cache, snapshot/load worker, log controller와 장식 정보를 한 수명으로 묶고, `RepositoryView`가 각 화면의 선택·스크롤·watch 상태를 가진다. `App`은 이를 UI와 입력 계층에 제공하는 façade이며 프로젝트를 닫으면 manager와 worker가 함께 정리된다. -`GitViewManager`가 저장소 경로·opaque id, repository cache, snapshot/load workers, commit-log controller, branch/tracking/ref decoration을 한 수명으로 묶는다. 그 안의 `RepositoryView`는 status/log/tree/diff pane, auto-follow, tree watcher dirty set, snapshot 기반 pending selection을 소유한다. `App`은 terminal·focus·fullscreen·notice·interaction을 소유한 채 이 manager의 명시적 façade만 UI와 입력 계층에 제공한다. 따라서 프로젝트 close는 manager를 drop해 worker를 함께 정리하고, daemon set adopt는 같은 manager에 opaque id만 붙여 선택·watcher·cache를 보존한다. +## Diff and file pipeline -## Git Diff Pipeline +- `SnapshotChannel`은 status 파일 목록, branch/tracking, HEAD oid와 refs fingerprint를 읽어 snapshot으로 보낸다. 워크트리 감시·읽기 정책은 [session.md](session.md#status-snapshot)을 따른다. +- 파일 선택, file view, commit file/diff, ref decoration은 `GitLoadWorker`가 `git2::Repository`와 함께 읽는다. 요청은 lane별 한 슬롯으로 합쳐지고 `(repository, generation, oid/path)`로 식별된다. 실행 중인 이전 요청은 중단하지 않지만 generation 또는 repository가 현재 의도와 다르면 UI가 결과를 버린다. lane은 공정하게 번갈아 처리되며 process-wide와 동일 repository의 동시 git I/O에는 상한이 있다. +- snapshot이 바뀌어도 선택 파일의 path·status·mtime이 모두 같으면 file/diff를 다시 읽지 않는다. 같은 파일의 in-place refresh는 scroll을 보존하고 새 선택은 scroll/search cursor를 초기화한다. -- **백그라운드 worker 스레드**: `SnapshotChannel`이 `load_snapshot`을 호출해 변경 파일 + tracking status를 `mpsc` 채널로 푸시한다(읽는 시점 규칙은 [session.md](session.md#상태는-시간이-아니라-변화에-따라-읽는다-runtimesnapshot_watchrs) 참고). -- **선택 로드 worker**: 파일/커밋 선택, file view, commit drill-down, ref decoration은 `GitLoadWorker`가 읽고 UI tick은 결과만 적용한다. `git2::Repository`는 `!Send`이므로 worker가 `Repository::discover`와 cache를 모두 소유한다. 요청은 `(repo, oid/path, generation)`으로 식별하고 diff/file/commit-files/decorations lane마다 아직 시작하지 않은 요청을 하나로 합친다. 실행 중인 이전 요청은 취소할 수 없지만 generation이나 repo가 현재 intent와 다르면 결과를 버리므로 연속 선택, HEAD 변경, 탭 전환이 과거 내용을 되돌리지 않는다. lane 선택은 round-robin이라 diff 요청이 계속 들어와도 file/commit-files/decorations가 굶지 않는다. 프로세스 전체 git I/O와 동일 저장소 I/O에는 각각 hard bound가 있고, 종료 제한 안에 끝나지 않은 worker handle도 중앙 registry가 bounded하게 추적·회수한다. -- **snapshot reload gate**: 선택 파일의 path·status columns·mtime이 전부 이전 snapshot과 같으면 다른 파일이 바뀌었더라도 선택 diff를 다시 읽지 않는다. 선택 파일 자체가 바뀐 in-place refresh만 기존 scroll을 유지해 요청하고, 새 선택은 scroll/search cursor를 새 대상에 맞춰 reset한다. -- **경로 검증**: 워크트리 안의 파일·디렉토리를 여는 경로는 전부 `git::path::resolve_in_workdir`를 거친다(파일 미리보기와 트리 리스팅 양쪽). plain relative 컴포넌트만 허용하고 `..`·절대경로·NUL·`.git`(대소문자 무시)을 거부하며, 워크디렉토리부터 한 컴포넌트씩 내려가 **모든 깊이의 심링크**를 막고 canonicalize containment로 마무리한다. 지금 호출자는 git이 만들어 낸 경로만 넘기지만, 검증을 호출부가 아니라 **파일시스템 경계**에 두어야 웹 표면이 요청 문자열을 같은 로더에 태워도 안전하다. 크기 검사와 읽기는 같은 파일 핸들에서, 트리 리스팅은 검증기가 돌려준 경로로 `read_dir`을 수행해 check→use TOCTOU를 닫는다. `.git` 판정은 `is_git_dir_name` 하나로 통일한다 — 대소문자와 후행 점·공백(NTFS가 버리는 문자)까지 흡수하며, 규칙을 두 군데에 따로 적으면 그 틈이 우회로가 된다. -- **렌더링**: 보이는 행(`scroll_start..scroll_start+visible_height`)에 한해 `syntect`로 syntax highlighting을 수행한다. 보이지 않는 라인은 highlighter state만 진행시켜 multi-line construct(블록 주석, 문자열 리터럴)의 연속성을 유지한다. +### Path gates -### 줄 번호 gutter (`ui/diff_viewer/gutter.rs`) +worktree 파일·디렉터리를 여는 모든 경로는 `git::path::resolve_in_workdir`를 거친다. 이 함수는 plain relative component만 허용하고 traversal·절대 경로·NUL·`.git`의 대소문자/플랫폼 변형과 모든 깊이의 symlink를 거부한 뒤 canonical worktree containment를 확인한다. 반환된 경로를 그대로 열어 check/use 사이의 재결합을 피한다. -`DiffLine`이 libgit2의 `old_lineno`/`new_lineno`를 그대로 들고 다닌다. 추가 줄은 old가, 삭제 줄은 new가 `None`이라 해당 칼럼을 비운다 — hunk 헤더에서 파생시키지 않는 이유는 kind별 카운터를 렌더 층에서 관리하게 되어 상태가 잘못된 층에 놓이기 때문이다. unified은 두 칼럼, split은 좌=old·우=new 한 칼럼씩, file view는 파일 자신의 번호를 보여준다. +commit object 또는 git pathspec만 다루는 경로에는 `validate_commit_path`를 사용한다. 파일시스템을 조회하지 않으므로 삭제된 파일의 historical diff도 유효하며, 위의 문자열 안전성은 그대로 적용된다. 웹의 route는 `with_repo`(파일을 열기) 또는 `with_repo_git_path`(git에 전달)를 통해 중앙 gate를 통과한다. -- **gutter와 본문은 반드시 별개 `Paragraph`여야 한다.** diff 계열은 수평 스크롤을 `Paragraph::scroll((0, x))`로 구현하는데 이건 라인을 통째로 밀기 때문에, 같은 paragraph에 있는 gutter는 `scroll_x > 0`이면 왼쪽으로 사라진다(실제로 file view에 그 버그가 있었다). `Block`을 따로 그리고 `block.inner`를 `Layout::Horizontal`로 쪼개 gutter는 `scroll((0,0))`, 본문만 스크롤한다. 수직 스크롤은 **어느 행을 담았는지**로 표현되므로 두 vector를 같은 루프에서 lockstep으로 채우는 것이 정렬을 지키는 유일한 수단이다. -- 폭은 로드된 hunk 전체의 최대 줄 번호에서 파생하고 최소 3자리(`MIN_LINENO_DIGITS`)를 보장한다. 보이는 창 기준으로 계산하면 스크롤 중에 본문 좌측 경계가 흔들린다. hunk 헤더 행도 같은 폭의 빈 gutter를 받아야 `@@`가 본문보다 한 칼럼 왼쪽에서 시작하지 않는다. -- `MIN_SPLIT_WIDTH`를 80 → 90으로 올렸다. 각 half가 gutter에 5칼럼을 쓰므로, 문턱을 그대로 두면 side-by-side 진입은 되지만 half당 읽을 수 있는 코드 폭이 조용히 줄어든다. +### Diff rendering -### 자동 줄바꿈 (`DiffPane::wrap`, diff pane focus에서 `w`) +`DiffLine`은 libgit2가 준 old/new line number를 보존한다. unified는 두 gutter 열, split은 old/new 한 열씩, file view는 파일 line number를 표시한다. gutter와 본문은 서로 다른 `Paragraph`로 렌더링해 horizontal scroll은 본문에만 적용하고, gutter 폭은 전체 hunk의 최대 번호와 최소 폭에서 계산한다. -ratatui `Paragraph::wrap`은 켜지면 `scroll.x`를 무시하므로(`render_paragraph`가 wrap 분기에서 `WordWrapper`만 쓰고 `LineTruncator`의 horizontal offset 경로를 타지 않는다) **줄바꿈과 수평 스크롤은 구조적으로 배타**다. 켤 때 `scroll_x`를 0으로 되돌린다 — 남겨두면 끌 때 낡은 오프셋이 되살아난다. +wrap 모드는 horizontal scroll과 함께 쓰지 않고 켤 때 `scroll_x`를 0으로 만든다. wrap 중 gutter는 본문에 포함하고, split은 wrap을 무시해 old/new 행 대응을 보존한다. vertical scroll과 검색 인덱스는 논리 줄 기준이다. `DiffPaneView`는 `Diff → Split → File` 순환을 제공하며 선택 파일을 열 수 없을 때 File 단계를 건너뛴다. -- 줄바꿈 모드에서는 **gutter를 본문 라인 안으로 접어 넣는다**. 본문 한 줄이 여러 화면 행을 먹는데 gutter 라인은 한 행이라, 두 paragraph를 나란히 두면 그 아래 전부가 어긋난다. gutter를 분리한 애초의 이유(수평 스크롤)가 이 모드엔 없으므로 인라인이 안전하다. 대가는 이어지는 행에 번호가 붙지 않는 것. -- **split 뷰는 줄바꿈을 무시한다.** 좌/우 half가 서로 다른 높이로 접히면 행 대응이 무너지는데, 그 대응이 이 레이아웃의 유일한 존재 이유다. -- 수직 스크롤은 여전히 **논리 줄** 단위다(렌더러가 창을 직접 슬라이스하고 ratatui의 vertical scroll을 쓰지 않는다). 따라서 줄바꿈이 켜진 채 긴 줄이 많으면 pane 높이보다 적은 논리 줄만 보이고 아래가 잘린다 — 스크롤로 전부 도달할 수 있으므로 감춰지는 내용은 없다. 검색 매치가 논리 행 인덱스라는 전제도 이 덕분에 유지된다. +## Status, tree and log state -### 표시 방식 전환 - -`DiffPaneView`는 `Diff`/`Split`/`File` 세 값인데 `v`(File 토글)와 `s`(Split 토글)는 각각 unified를 기준으로 한 축만 오간다 — 세 번째가 있다는 걸 모르면 발견할 수 없다. `Tab`(`App::cycle_diff_view`)이 `Diff → Split → File → Diff`로 셋을 모두 순회해 집합을 드러내고, `v`/`s`는 아는 뷰로 바로 가는 용도로 남는다. File 단계는 `can_open_file_view`가 거짓이면(선택 없음 / 해석 불가한 커밋 파일) 건너뛴다 — 순회 중 죽은 입력을 만들지 않기 위함이다. Tree 모드는 우측 pane이 항상 파일 미리보기라 순회 대상이 없어 no-op이다. - -## Status filter cache - -`StatusView::filter_cache`는 `search_query` 또는 `files`가 변경될 때만 재계산된다 (`recompute_filter`). 렌더러와 navigation helper는 캐시된 슬라이스를 읽기만 한다. - -## File-Tree Navigator (`ViewMode::Tree`) - -` b`로 진입하는 read-only 디렉토리 트리. 좌측 리스트가 워크트리 전체를 탐색하고, 파일 선택은 기존 file-view pane(`DiffPaneView::File`)을 재사용한다 — 새 렌더 경로를 만들지 않는다. - -- **Lazy one-level reads**: `git::tree::read_children`가 `std::fs::read_dir`로 정확히 한 디렉토리 레벨만 읽는다. 펼치지 않은 서브트리는 절대 walk되지 않는다. `.gitignore` 필터링은 libgit2를 통하고(`[tree] respect_gitignore`), symlink는 non-directory로 보고해 visited-set 없이 순환을 차단한다. -- **Derived rows**: `TreeView`는 per-directory child cache와 expanded set만 저장하고, 보이는 행 리스트는 `visible_rows`로 매번 파생한다 — 확장 상태와 flatten된 뷰가 어긋날 수 없다. 디렉토리 I/O는 전부 `app/tree.rs`(UI 스레드 동기)에 있어 populated cache가 주어지면 `tree_view.rs`는 순수하고, 파일시스템 없이 단위 테스트된다. -- **파일명 검색**: 트리 focus에서 `/`가 검색 오버레이를 열 때 `build_tree_index`가 `max_depth`까지 전체 트리를 한 번 walk해 flat index를 만들고, 이후 필터링은 인메모리다. `Enter`는 선택 경로의 조상 디렉토리를 모두 펼쳐 일반 뷰에서 reveal한다. -- **Live watch**: `runtime::tree_watch`가 notify(+debouncer-mini)로 **펼친 디렉토리만 비재귀로** 감시한다(yazi/broot/nvim-tree와 같은 전략) — 워크트리 전체 재귀 감시는 디렉토리당 inotify watch 하나를 소비해 대형 트리에서 무너진다. `[tree] live_watch = false`면 Tree 진입 시에만 재조회한다. -- **Read-only 보장**: 트리는 어떤 쓰기·이름변경·삭제도 수행하지 않는다. -- **세션 지속성**: expanded set과 선택 경로는 세션에 저장·복원되며, 복원 시 unsafe 경로와 사라진 디렉토리의 stale 확장은 정리된다. - -## HEAD Change Detection - -snapshot worker는 매 폴 사이클마다 현재 HEAD oid를 함께 보고한다. UI 스레드는 `poll_snapshot`에서 oid 변동을 감지하면 `refresh_commit_log_after_head_change`로 commit log와 drill-down 상태를 동일 oid 기준으로 재정렬해, 터미널에서 새 커밋·amend·force-push·브랜치 전환이 일어났을 때도 로그 뷰가 즉시 따라잡는다. - -## Commit Log Decoration - -`git log --decorate`가 주는 방향 감각을 로그 뷰에 옮긴 것이다. `src/git/diff/refs.rs`가 `repo.references()`를 한 번 걸어 `Oid -> Vec` 맵을 만들고, HEAD·로컬 브랜치·태그·원격 브랜치를 구분해 커밋 행에 chip으로 그린다. 비용은 커밋 수가 아니라 **ref 수**에 비례하고, annotated tag은 `peel_to_commit`으로 가리키는 커밋에 붙인다. - -- **재생성 시점은 refs fingerprint가 정한다**: fetch가 `origin/dev`를 옮기면 HEAD는 그대로여도 chip은 달라져야 한다. snapshot worker가 매 폴마다 ref 이름·타깃의 다이제스트를 `RepoSnapshot::refs_fingerprint`로 실어 보내고, UI 스레드는 그 값이 바뀔 때만 맵을 다시 만든다. 재생성 실패는 이전 맵을 유지한다 — 일시적 읽기 오류로 chip이 사라지는 것보다 낫다. -- **ahead/behind는 위치가 아니라 oid 집합으로 판정한다**: 이전 구현은 "위에서 N개가 ahead"라는 위치 가정이었고, anchor가 HEAD가 아니거나 필터가 걸리면 마커가 엉뚱한 행에 붙었다. 지금은 `revwalk.push(local)` + `hide(upstream)`(과 그 반대)로 각 방향의 oid 집합을 만들어 멤버십으로 판정한다. 집합은 방향당 `MAX_DIVERGENCE_OIDS`개로 끊는다 — walk가 최신순이므로 잘리는 쪽은 화면에 닿지 않는 꼬리다. -- **1 커밋 = 1 행을 유지한다**: `log_view.selected`가 커밋 인덱스이자 화면 위치라는 전제를 선택·스크롤·tail prefetch가 공유한다. 여유 공간은 행이 아니라 **컬럼**으로 쓴다. `area.width >= MIN_DETAIL_WIDTH`이면 상대 시각 대신 절대 시각, author에 email, short_id 10자, chip 무절단으로 넓힌다. 판정 기준이 `list_fullscreen` 플래그가 아니라 폭인 이유는 넓은 모니터에서는 fullscreen이 아니어도 자리가 남기 때문이고, `MIN_SPLIT_WIDTH`가 이미 세운 선례와 같은 모양이다. -- **commit graph는 범위 밖이다**: lane graph는 topological 정렬을 전제하는데 현재 revwalk에는 `set_sorting`이 없고, 정렬을 바꾸면 anchor+skip 페이지네이션 계약까지 함께 다시 설계해야 한다. +- `StatusView::filter_cache`는 query 또는 file list가 바뀔 때만 재계산한다. status의 staged/worktree 두 열은 하나의 `StatusKind`를 사용하고, rename은 유효한 new-side `path`와 표시용 `old_path`를 분리한다. 정렬은 결정적이어야 하며 typechange와 conflict를 modified로 합치지 않는다. +- Tree는 `git::tree::read_children`로 한 directory level만 lazy-read한다. directory 우선·이름순으로 정렬하고 `.git`, 거부된 path component, non-UTF-8 이름과 gitignore 대상은 숨긴다. symlink는 directory로 따라가지 않는다. Tree는 read-only다. +- Tree의 visible rows는 child cache와 expanded set에서 파생한다. 파일명 검색은 제한된 깊이/방문 수의 flat index를 만들고, 선택 결과를 reveal할 때 조상을 확장한다. live watch는 펼쳐진 directory만 non-recursive로 감시하며 비활성화할 수 있다. expanded set과 선택 경로는 안전성 검사 후 프로젝트 view state로 저장한다. +- snapshot이 보고한 HEAD oid가 변하면 log와 drill-down을 같은 oid 기준으로 갱신한다. refs fingerprint가 변할 때만 ref map을 재생성하고, HEAD·local/remote branch·tag label은 oid 집합으로 ahead/behind를 판정한다. commit row는 한 commit 한 행을 유지한다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/plugin-host.md b/docs/architecture/plugin-host.md index 1e4f120c..2298b95b 100644 --- a/docs/architecture/plugin-host.md +++ b/docs/architecture/plugin-host.md @@ -1,54 +1,37 @@ # Plugin Host -어떤 CLI가 사용량 한도에 걸렸는지 알아보고 한도가 풀린 뒤 세션을 재개하는 일은 provider를 아는 동작이다. 코어는 그런 ontology를 갖지 않으므로 그 지식을 **별도 프로세스로 분리한다** — 코어 `src/plugin/`에는 provider를 모르는 host만 두고, Claude Code / Codex / OpenCode를 아는 코드는 `plugins/nightcrow-recovery`에 산다. 코어 어디에도 그 세 이름은 나오지 않으며, 그것이 이 경계가 지켜지고 있다는 **검사 가능한 조건**이다. +plugin은 provider별 감지·복구를 담당하는 별도 child process다. 코어는 provider 이름이나 출력 의미를 해석하지 않고 pane, idleness, relaunch와 같은 일반 계약만 제공한다. plugin이 없거나 실패해도 pane 자체의 실행과 터미널은 계속된다. -**이 기능은 provider의 한도를 우회하지 않는다.** 하는 일은 사람이 손으로 하던 것 — 한도가 풀릴 시각까지 기다렸다가 같은 세션을 다시 여는 것 — 을 대신하는 것뿐이다. 한도를 늘리거나 회피하거나 감지를 피하는 경로는 없고, 있어서도 안 된다. +## Process and wire boundary -## 프로세스 경계와 도달 범위 +host는 repository hub마다 설정에 허용된 plugin child를 하나씩 실행하고 stdin/stdout으로 newline-delimited JSON(NDJSON)을 주고받는다. host → plugin은 `PaneOpened`, `PaneOutput`, `PaneIdle`, `PaneExited`, `PaneClosed`, `UserInput`, `Shutdown`, plugin → host는 `SendInput`, `Relaunch`, `Status`, `WatchPane`, `Attention`, `Log`를 사용한다. 독립 배포되는 plugin과 host는 `PROTOCOL_VERSION = 3`이 다르면 거부한다. -- **plugin 프로세스는 저장소마다 하나다**: `Plugins::start`는 `TerminalHub::run` 안에 있고 hub은 저장소마다 하나이므로(`session/catalog`), 프로젝트가 여섯이면 켜 둔 plugin도 여섯 벌 뜬다. 이것이 경계의 형태다 — pane과 마찬가지로 plugin도 저장소 단위로 격리된다. 그 대가로 **plugin은 자신이 유일하다고 가정할 수 없다**. host는 hub의 경로에서 runtime 디렉터리를 유도해 `NIGHTCROW_PLUGIN_RUNTIME_DIR`로 plugin 자식과 그 hub의 pane 양쪽에 심는다 (`backend::identity::plugin_runtime_dir`). 양쪽이 같은 입력에서 같은 값을 계산하므로 한쪽이 다른 쪽에게 알려줄 배관이 없고, pane 안의 helper는 토큰을 읽듯 이 값을 읽어 **자기 pane을 보고 있는 인스턴스**의 소켓으로 간다. 경로 대신 고정 폭 digest를 쓰는 이유는 AF_UNIX 경로 상한이 107바이트 부근이고 저장소 경로만으로 그 대부분을 쓸 수 있기 때문이다. -- **Windows에서 plugin은 콘솔을 열지 않는다**: 백그라운드 세션은 `DETACHED_PROCESS`로 도므로 물려줄 콘솔이 없고, Windows는 그럴 때 콘솔 subsystem 자식에게 **새 콘솔을 할당한다**. plugin마다 창이 하나씩 뜨고 그 창을 닫으면 plugin이 죽는다. 자식의 파이프는 전부 host가 열어주므로 콘솔이 필요 없어 `CREATE_NO_WINDOW`로 막는다(`plugin/host.rs`). +한 줄은 64 KiB, plugin이 보내는 pane input은 8 KiB로 제한한다. embedded newline, malformed JSON, unknown version과 payload bound 위반은 명령으로 만들지 않고 거부·기록한다. host reader는 bounded queue를 사용하며 host가 종료하면 child를 정리한다. Windows에서는 파이프만 사용하고 child에 새 console을 만들지 않는다. -- **왜 자식 프로세스 + NDJSON인가**: Rust에는 안정 ABI가 없어 `libloading` 기반 dylib plugin은 버전이 어긋나는 순간 UB다. cargo feature 게이트는 재컴파일을 요구하므로 "설치·제거 가능"이 아니다. 남는 것은 프로세스 경계이고, 그 편이 신뢰 모델도 정직하다 — plugin은 우리 주소 공간에 없다. 프레이밍은 stdin/stdout의 개행 구분 JSON이고 버전(`v`)이 맞지 않는 줄은 거부한다. -- **도달 범위의 기본은 opt-in, 확장은 증거로만**: plugin은 `[[startup_command]]`이 `plugin = "이름"`으로 지목한 pane을 본다. 여기에 `[[plugin]]`의 `watch_on_signal`(기본 `false`)을 켜면 두 번째 경로가 열린다 — **pane 자신의 토큰을 제시한 요청**, 즉 `PluginCommand::WatchPane { token }`이다. 토큰은 spawn 시각에 그 pane의 자식 환경에만 들어가고 (`pty_spawn.rs`, 명령 없이 연 pane도 예외 없이) 자식들이 상속하므로, 토큰을 말할 수 있는 것은 그 pane 안에서 도는 프로세스뿐이다. **근거가 열거가 아니라 증명이라는 것이 핵심이다**: plugin에게 pane 목록을 주는 경로는 여전히 없고, 맨 셸은 어떤 provider helper도 띄우지 않으므로 영원히 채택되지 않는다. `[[plugin]]`은 `enabled = false`가 기본이다. -- **왜 그 확장이 필요했나**: 압도적으로 흔한 사용은 ` t`로 셸을 열고 `claude`를 손으로 치는 것이다. 그 pane은 `create_pane_with(None, None)`으로 열려 launch command가 없고 `detect(None)`은 어떤 provider도 붙이지 못한다 — 그래서 recovery가 **아무것도** 하지 않았다. `WatchPane`은 그 구멍만 메운다. `PROTOCOL_VERSION`은 그래서 2가 되었고, 이 명령은 `generation`을 싣지 않는다: 들어본 적 없는 pane에 대해 어느 spawn인지 정직하게 주장할 수 없으므로, 답으로 오는 `PaneOpened`가 그것을 말한다. `Plugins::start`도 그래서 조건이 둘이다 — enabled이고 **(opt-in됐거나 `watch_on_signal`)**. -- **요청은 plugin 쪽에서 먼저 줄인다**(`runloop_adopt.rs`): 거부는 응답이 없는 것과 구별되지 않으므로 답을 못 받은 요청이 타이트 루프가 되거나 낯선 토큰마다 상태를 남기면 안 된다. 미해결 요청은 `MAX_PENDING`개까지만 들고(초과분은 새 것을 버려 실패를 닫힌 방향으로 낸다), 같은 토큰은 `REQUEST_COOLDOWN` 동안 다시 묻지 않는다 — Claude Code의 statusline은 매 렌더마다 돌기 때문에, 이게 없으면 남의 pane 하나가 host의 tick당 예산을 정작 필요한 요청과 함께 태운다. 그리고 요청을 정당화한 **신호는 버리지 않고 들고 있다가 `PaneOpened` 뒤에 재생한다**: 신호가 pane보다 먼저 도착하고 host는 새로 넘긴 pane에 어떤 history도 재생해 주지 않으므로, 버리면 지금 복구해야 할 그 한도가 사라진다. 이때 provider는 명령줄이 아니라 `detect_from_signal`이 고른다 — `SignalKind`는 정확히 한 adapter의 helper만 발행하므로 신호 종류 자체가 증거이고, 그래서 두 번째 sniffing 경로가 아니라 wire kind에 대한 lookup이다. -- **늦게 채택된 pane은 relaunch되지 않는다**: launch command가 `None`이므로 프로세스를 되돌려 놓으면 provider가 아니라 셸이 다시 뜬다. guard는 이것을 `Refused::NoLaunchCommand`로 — 인자 문제와 구별되는 자기 이유로 — 거부하고, `allowed_resume_flags`를 어떻게 열어도 통과하지 않는다. hub도 같은 판단을 한다: watched pane이 종료했을 때 `is_relaunchable`이 거짓이면 `PENDING_RELAUNCH_TTL` 동안 slot을 붙잡는 대신 곧바로 닫는다. 이런 pane이 받을 수 있는 recovery는 살아 있는 프로세스에 타이핑하는 것 하나뿐이고, plugin 쪽도 같은 결론을 미리 내려 `NeedsAttention`으로 간다(`state_resume.rs`). +## Trust boundary -## 신뢰 경계 (`guard.rs`) +`protocol::decode_command`는 shape/size만 검사하고 `Guard::judge`가 모든 권한을 판정한다. guard를 우회해 PTY나 session을 조작하는 경로는 없다. -`protocol::decode_command`는 모양과 크기만 본다. 권한은 `Guard::judge`만 판단하고 plugin이 우회할 경로가 없다. 규칙: pane이 존재하고 opt-in했는가, `generation`이 현재와 같은가(이것이 교체된 프로세스에 대한 결정이 후임에게 닿는 것을 막는다), 살아 있고 조용할 때만 입력을 넣는가, 죽었을 때만 relaunch하는가, 되돌릴 명령이 있는가, 제어문자가 섞이지 않았는가, slot당 횟수 상한 안인가. 거부는 로그로 남고 재시도되지 않는다. +- startup command가 plugin을 지목한 pane만 기본 opt-in 대상이다. `watch_on_signal`이 켜진 경우에만 `WatchPane`이 이 범위를 넓힐 수 있다. +- `PaneToken`은 pane spawn 때 난수로 만들고 그 pane의 child environment에만 주입한다. `WatchPane`은 token으로 pane을 찾고, operator permission·다른 watcher 여부·live process를 다시 확인한다. token은 identity/correlation key이지 단독 authorization이 아니다. +- pane-scoped command는 token과 `PaneGeneration`을 함께 요구한다. generation이 교체된 process와 다르면 거부하고, SendInput은 live·idle 조건을, Relaunch는 exited·원래 launch command 존재 조건을 만족해야 한다. plugin은 executable/command를 임의로 선택하지 못한다. +- `allowed_resume_flags`에 없는 relaunch flag/option은 거부한다. 원래 command line을 기반으로 검증된 한 줄만 실행하고, 입력·relaunch 승인 횟수는 pane id가 아니라 relaunch를 가로지르는 token별 window budget으로 센다. +- watcher는 pane 하나당 하나만 허용한다. plugin 자체의 pending request와 outbound event도 bounded하며, pane을 열거해 선택하는 API는 없다. -- **pane을 얻는 규칙만 따로 산다**(`guard_watch.rs`): 나머지 규칙이 모두 "이미 배정된 pane"에서 출발하는 데 반해 이것은 배정 자체를 만드는 유일한 자리라, 큰 판단 안의 분기가 아니라 조건 목록 하나로 읽히게 분리했다. 순서대로 — 토큰이 아는 pane인가, `watch_on_signal`이 켜졌는가, 다른 plugin이 이미 보고 있지 않은가(pane 하나에 watcher 하나. 둘이 같은 키보드를 몰면 서로가 바꾸는 상태 위에서 recovery가 섞인다), 프로세스가 살아 있는가. **예산은 청구하지 않는다** — pane을 받는 것은 pane에 하는 일이 아니고, 이어질 행위는 각각 청구된다. 이미 자기 것인 pane을 다시 물으면 **거부가 아니라 승인**이다: 명령줄로는 안에 있는 것을 알아볼 수 없었던 opt-in pane이 다시 시도할 유일한 방법이 `PaneOpened`를 한 번 더 받는 것이기 때문이다. 알 수 없는 토큰이 압도적 다수라는 것도 이 설계의 전제다 — 같은 사용자의 다른 nightcrow 세션 pane들이 같은 소켓에 닿는다. -- **`PaneToken`이 정체성인 이유**: `PaneId`는 backend별 카운터라 backend가 다시 만들어지면 1로 돌아간다. cwd도 답이 못 된다 — 한 저장소에 여러 pane을 두는 것이 지원되는 레이아웃이다. 그래서 난수 토큰을 spawn 시각에 자식 환경(`NIGHTCROW_PANE_TOKEN`)으로 넣는다. provider가 띄우는 hook/statusline 자식들이 이를 상속하므로 plugin은 어떤 pane에서 온 사건인지 추측 없이 안다. -- **횟수 상한은 slot(토큰) 기준으로 센다**: relaunch는 반드시 새 `PaneId`를 만든다. 상한을 id로 세면 relaunch마다 예산이 새로 생겨, 즉시 끝나는 명령과 매 종료마다 relaunch하는 plugin이 만나면 상한에 영원히 닿지 않는다. 토큰은 relaunch를 건너 살아남는 유일한 값이다. -- **relaunch는 같은 id를 되살리지 않는다**: id는 단조 증가하고 모든 클라이언트가 `Exited`를 그 id의 종결로 취급한다. 교체는 새 id로 태어나되 토큰을 물려받고 generation이 오른다. 레이아웃은 새 pane을 원래 인덱스에 넣고 기존 `Reordered`를 브로드캐스트해 보존한다 — 와이어 포맷에 relaunch 전용 메시지를 추가하지 않는다. -- **프로세스 해제와 slot 폐기를 분리한다**: 한도 대기는 몇 시간일 수 있다. 죽은 자식의 fd와 스레드를 그 시간 내내 붙잡는 것은 낭비이므로 `release_process`는 PTY를 놓고 slot만 남긴다. 아무도 relaunch하지 않으면 `PENDING_RELAUNCH_TTL`에 slot을 폐기한다. -- **권한 인자는 사용자가 선언한다**: relaunch의 첫 토큰(플래그 또는 subcommand)과 `-`/`/`로 시작하는 option 토큰은 `[[plugin]].allowed_resume_flags`에 있어야 하며 기본은 빈 목록이다. 코어가 특정 CLI의 위험 플래그 이름을 하드코딩하면 provider 경계를 깨므로 택하지 않았다. 허용 문자는 POSIX shell과 `cmd.exe`에서 그대로 전달되는 안전한 공통 집합으로 제한하며 별도 quote 문자를 넣지 않는다. 원래 명령 문자열은 수정하지 않아 다음 relaunch에 인자가 누적되지 않는다. -- **와이어 계약이 두 벌 있다**: plugin은 독립 빌드라 `plugins/nightcrow-recovery`가 프로토콜 타입을 따로 갖는다. `PROTOCOL_VERSION`을 진짜 주장으로 만들려면 그래야 하고, 양쪽 모두 JSON 모양을 리터럴로 고정한 테스트가 있어 드리프트는 테스트 실패로 나타난다. +relaunch는 같은 `PaneId`를 부활시키지 않고 새 id와 증가한 generation으로 만든다. slot은 process와 분리해 잠시 유지하고 `PENDING_RELAUNCH_TTL`이 지나면 폐기한다. bare shell처럼 재현할 launch command가 없는 pane은 relaunch하지 않고 바로 attention/종료 경로로 간다. -## provider 쪽 (`plugins/nightcrow-recovery`) +## Recovery plugin boundary -- **provider의 설정 파일은 병합만 한다**(`hooks.rs` / `hooks_merge.rs`): `~/.claude/settings.json`은 사용자 것이고 우리가 모르는 키를 담고 있을 수 있으므로, 모든 수정은 우리가 넣지 않은 것을 보존하는 병합이고, 파일을 이해할 수 없으면(JSON이 아니거나 top-level이 object가 아니면) 추측하는 대신 멈춘다. 쓰기는 같은 디렉터리의 temp file → rename이고 모드 `0600`은 rename **전에** 건다, 첫 쓰기 전에 `.bak`을 남긴다. 등록하는 hook event는 정확히 하나다 — `HOOK_EVENT = "StopFailure"`, `HOOK_MATCHER = "rate_limit"` 아래 `{"type":"command","command":" hook","timeout":5}`. 최소 권한이라서 그렇다: `authentication_failed`·`billing_error` 같은 무관한 실패의 payload는 이 프로세스에 아예 도달하지 않고, 그 대가로 일시적 `overloaded`/`server_error`는 pane 출력에서 알아본다. 우리 엔트리를 알아보는 표시는 `command` 문자열에 `MARKER`가 들어 있는지 하나뿐이다 — provider의 스키마에서 자유 텍스트를 넣을 수 있는 필드가 거기뿐이다. 그래서 install은 `current_exe()`로 해석한 절대 경로가 `MARKER`를 담지 않으면 **거부한다**(나중에 uninstall이 자기 엔트리를 못 알아본다). 경로를 `argv[0]`이 아니라 해석해서 쓰는 이유는 그 파일을 읽는 것이 작업 디렉터리가 다른 프로세스라는 것이다. -- **helper는 provider의 임계 경로에 있으므로 최소한만 한다**(`helper.rs`): 등록되는 명령은 이 plugin의 바이너리를 내부 서브커맨드로 다시 부르는 것이다(`Mode::Hook` / `Mode::Statusline`). `hook()`은 stdin을 상한까지만 읽고 `["session_id","error_type","hook_event_name"]`만 통과시킨다 — **whitelisting이 프라이버시 경계다**. `StopFailure` payload는 transcript 파일 경로와 provider의 에러 산문을 담으므로, 상태 기계가 실제로 읽는 필드만 소켓을 건넌다. 어느 실패도 호출자에게 보고하지 않는다 — 돌지 않는 recovery plugin은 설치되지 않은 것과 정확히 같아 보여야 한다. -- **IPC 랑데부는 경로 규칙 하나다**(`ipc.rs`): `$XDG_RUNTIME_DIR/nightcrow/recovery.sock`, 없으면 `~/.nightcrow/run/recovery.sock`. 디렉터리는 `0700`, 소켓은 `0600`이고 bind마다 다시 건다. 남아 있는 소켓 파일은 **아무도 듣고 있지 않을 때만** unlink한다. `parse_line`은 줄 크기, JSON object 여부, `v` 일치, 토큰의 문자 집합과 길이, 아는 `kind`, object payload를 모두 검사하고 실패마다 무엇이 틀렸는지 말한다 — 여기가 untrusted input이 상태가 되는 경계이므로 조용히 강제 변환하는 필드가 곧 버그다. **토큰은 correlation key이고 authorisation이 아니다**: 위조된 메시지가 할 수 있는 최대는 이 plugin이 host에게 무언가를 묻게 만드는 것이며 그것은 guard가 처음부터 다시 판단한다. -- **statusline은 가로채지 않고 이어붙인다**(`helper_statusline.rs` / `helper_delegate.rs`): `statusLine`은 목록이 아니라 명령 하나라 install은 사용자 것을 반드시 밀어낸다. 지금은 `helper::statusline()`이 pass-through다 — stdin 바이트를 **그대로** 보관하고, 사본만 파싱해 `rate_limits`를 IPC로 넘기고, sidecar에 기록해 둔 밀려난 명령을 그 원본 바이트를 stdin으로 주어 실행한 뒤 그 stdout을 출력한다. 재직렬화하지 않는 이유는 키 순서와 숫자 표기가 provider의 것이기 때문이다. 실행은 `sh -c`로 한다 — Claude Code가 `statusLine` 명령은 셸에서 돈다고 문서화하고 자기 예시가 `~`, `jq` 파이프, 인라인 `$(...)`에 의존한다. `$SHELL`이 아니라 `sh`인 것은 대화형 셸이면 refresh마다 rc 파일을 읽기 때문이다. 예산은 2초이고 넘기면 죽이고 우리 줄로 떨어진다 — 이 상한은 끝나지 않는 명령이 이 프로세스를 불멸로 만들지 않게 하기 위한 것이다. stderr는 버린다. sidecar에 든 것이 우리 자신의 바이너리면 다시 실행하지 않는다(`is_ours` 재사용). 모든 실패 경로는 plugin 자신의 줄로 격하된다 — 에러를 띄우는 statusline은 평범한 statusline보다 나쁘다. **비자명한 함정 하나**: 밀어낼 `statusLine`이 애초에 없었으면 `merge_into`가 `Some(Value::Null)`을 돌려주므로 **sidecar가 `null`을 담을 수 있다**. 없음만이 빈 경우가 아니고, `null`도 "실행할 것이 없다"로 읽어야 한다. -- **관측 부담을 지지 않는 쪽으로**: 출력 텍스트는 chunk 단위로 escape를 벗겨 넘기므로 두 read에 걸친 escape는 완전히 제거되지 않는다. 허용되는 이유는 출력 텍스트가 언제나 fallback 신호일 뿐이라는 것이다 — Claude는 hook과 statusline, Codex는 rollout JSONL, OpenCode는 로컬 서버의 세션 상태가 1차 신호다. -- **신호의 역할은 분리돼 있고, 이것이 하중을 받는 사실이다**(`provider/claude.rs`): 한도를 **선언**할 수 있는 것은 `StopFailure`(`on_stop_failure`)와 출력 fallback뿐이다. statusline은 정확한 reset epoch만 공급하고 결코 선언하지 않는다 — `on_rate_limits`는 `resets_at`만 기억하고 `used_percentage`는 100이어도 의도적으로 무시한다. 여러 창이 보고되면 가장 이른 것이 유용한 deadline이다. 이 분리의 결과가 `state_clock.rs`의 `arm_wait`에서 갈린다: `LimitKind::UsageLimit`이고 `resets_at`이 알려져 있으면 `WaitingForReset`으로 **정확히 한 번** 기다리고 resume attempt를 쓰지 않는다. 모르면 `arm_backoff`로 떨어지고, 그쪽은 attempt 예산에 묶인 재시도 루프라 `MAX_RESUME_ATTEMPTS`에 닿으면 `NeedsAttention`으로 끝난다. 그래서 hook과 statusline을 둘 다 설치하는 것의 실질적 이득은 "감지"가 아니라 **기다림이 정확해지고 예산을 쓰지 않는다**는 것이다. -- **OpenCode에는 개입하지 않는다**: 자체 재시도가 상한 없이 계속되므로 "재시도 소진"을 기다리는 설계가 성립하지 않는다. 프로세스가 끝났거나 상태가 `idle`로 바뀐 뒤에만 손을 댄다. +`plugins/nightcrow-recovery`가 provider-specific adapter를 맡는다. bundled recovery는 host가 전달한 launch command에서 Codex CLI와 OpenCode를 식별하고, provider별 session id·reset 시각·resume 인자를 plugin 안에서만 해석한다. Codex는 rollout JSONL에서 unambiguous session id와 usage-limit reset을 읽어 `codex resume `를 제안한다. OpenCode는 `/session/status`를 관찰하고 retry 중에는 개입하지 않으며, live process가 `idle`이 되면 `NeedsAttention`을 보고하고 process가 끝난 뒤에만 `--session ` relaunch를 제안한다. provider 한도를 우회하지 않으며 transcript나 원본 payload를 host 계약 밖으로 보내지 않는다. -## Recovery Surface (사람이 보고 취소하는 쪽) +## Config reload -plugin의 `status` 보고는 `ServerMessage::Recovery { pane, state, detail?, deadline_epoch?, attempt }`로 모든 클라이언트에 브로드캐스트되고, 사람은 `ClientMessage::CancelRecovery { pane }`로 되돌려 준다. +`[[plugin]]`의 enabled/opt-in과 live host 목록은 repository hub의 worker에서 적용한다. `command`·`args`·`env`만 child 교체를 일으키며, `allowed_resume_flags`·`watch_on_signal`은 다음 guard 판정부터 바꾼다. 이미 pane을 보고 있는 plugin은 명시적으로 `enabled = false`가 되기 전까지 유지한다. 후계자 spawn이 실패하면 기존 pane의 hold를 버려 owner 없는 recovery를 만들지 않는다. guard와 token budget은 reload마다 재생성하지 않는다. -- **hub는 보고를 보관하지 않는다**: 도착한 그대로 브로드캐스트하고 잊는다. hub가 소유하는 것은 hold(exited pane의 slot)뿐이고 사람이 빼앗을 수 있는 것도 그것뿐이다. 따라서 표시 상태는 클라이언트가 최신 보고를 들고 있는 것으로 성립한다. -- **`state`는 해석하지 않는다**: plugin이 고른 짧은 문자열이며 코어는 뜻을 모른다. 유일한 예외가 hub 자신이 보내는 `"cancelled"`(`hub_recovery::RECOVERY_CANCELLED`)이고, 클라이언트는 이것을 "이 pane에 더는 대기 중인 것이 없다"로 읽어 엔트리를 **지운다**. -- **hold가 끝나는 모든 경로가 `cancelled`를 보낸다**: 취소, TTL 만료, relaunch 성공, 명시적 close. 하나라도 빠지면 클라이언트에 지나간 deadline이 영구히 남는다. -- **취소는 hold를 근거로 판정한다**: `claim_pending`이 비면 아무 일도 하지 않는다(에러가 아니다 — 클라이언트는 만료보다 한 박자 늦을 수 있다). hold가 있으면 `pane_closed` → `Plugins::forget` → `retire_slot` 순서다. `forget`이 slot의 토큰으로 예산을 지우므로 `retire_slot`보다 앞이어야 한다. -- **TUI는 행을 추가하지 않는다**: 표시는 (1) pane 탭 라벨의 짧은 마커(`⏳17:45` / `⚠3`, `ui/terminal_tab/recovery.rs`)와 (2) notice row 마지막 칩(`ui/notice.rs`)뿐이다. 전용 행이나 오버레이를 만들지 않은 이유는 Layout·Notice Row와 같다 — 행이 생겼다 사라지면 열려 있는 모든 PTY가 리사이즈된다. 좁은 pane에서는 제목이 먼저 잘리고 마커가 남는다(`RECOVERY_TITLE_MAX_CHARS`). -- **취소 키는 leader 뒤에 있다**: ` c`. bare 키는 pane 안 프로그램의 것이라는 Keyboard Routing 규칙 그대로이며, 대기 중인 것이 있을 때만 힌트에 노출된다. -- **탭이 없는 pane도 가리킬 수 있어야 한다**: 프로세스가 끝나고 slot만 남은 pane은 클라이언트의 pane 목록에 없다. 그래서 표시·취소 대상은 "focus된 pane의 보고, 없으면 목록에 없는 pane의 보고(가장 낮은 id)"로 정의된다(`TerminalState::recovery_focus`, 웹은 `lib/recovery.ts::orphanRecovery`). 웹에서는 그런 보고가 pane 셀 대신 패널 툴바에 뜬다. -- **deadline은 절대 추측하지 않는다**: `deadline_epoch`가 없으면 시각을 아무것도 그리지 않는다. 틀린 벽시계 시각은 사실처럼 읽힌다. TUI는 날짜 크레이트 없이 `libc::localtime_r`로 `HH:MM`만 만들고 (`ui/wall_clock.rs`), unix가 아닌 플랫폼에서는 UTC로 떨어진다. -- **터미널 렌더링과 결합하지 않는다**: 화면 내용이 아니라 pane 메타데이터이므로 emulator/xterm 경로에 닿지 않는다. TUI는 `TerminalState.recovery` 맵, 웹은 컨트롤 프레임에서 파생된 상태다. +## Recovery surface + +plugin의 `Status`와 `Attention`은 hub가 의미를 해석하지 않고 클라이언트 모두에 broadcast한다. hub가 보관하는 것은 exited pane slot의 relaunch hold뿐이다. 취소·TTL 만료·relaunch 성공·pane close로 hold가 끝날 때는 `Recovery { state: "cancelled" }`를 보내 stale deadline을 지운다. `CancelRecovery`는 hold가 있을 때만 `pane_closed → forget → retire_slot` 순서로 처리한다. + +TUI는 pane tab marker와 notice row chip으로, web은 terminal toolbar/pane metadata로 recovery를 표시한다. 전용 행이나 터미널 화면 overlay는 만들지 않아 PTY geometry를 바꾸지 않는다. deadline이 없으면 시각을 추측해 그리지 않으며, recovery detail은 짧은 host/plugin text만 전달하고 transcript나 원본 payload는 전달하지 않는다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/session.md b/docs/architecture/session.md index 49331709..336bcfdf 100644 --- a/docs/architecture/session.md +++ b/docs/architecture/session.md @@ -1,10 +1,8 @@ # Session & Backend -세션 데몬이 소유하는 것과 클라이언트가 각자 갖는 것의 경계, 그 경계를 표현하는 `TerminalBackend` trait, 살아 있는 세션에 설정을 다시 읽히는 경로, 그리고 백그라운드 worker의 종료 정책을 다룬다. 이 문서의 결정은 대부분 "표면이 여럿"이라는 하나의 사실에서 파생된다 — 한 세션에 attach한 TUI와 브라우저가 동시에 붙어 있을 수 있다. +`session/`은 transport-neutral 세션 상태를 데몬이 소유하는 경계다. attach TUI와 web viewer는 각자 요청·인증·wire를 이 operation에 번역하며 catalog, hub, preference, PTY 크기 소유권을 직접 갖지 않는다. -## TerminalBackend Trait - -`TerminalBackend`는 pane 추상화다. 구현체가 둘이고, 둘의 차이가 이 trait의 모양을 정했다. +## TerminalBackend ```rust trait TerminalBackend { @@ -12,147 +10,59 @@ trait TerminalBackend { fn destroy_pane(&mut self, id: PaneId); fn send_input(&mut self, id: PaneId, data: &[u8]) -> Result<()>; fn resize(&mut self, id: PaneId, rows: u16, cols: u16) -> Result; - fn reorder(&mut self, order: &[PaneId]); // 기본 no-op - fn claim_size(&mut self); // 기본 no-op + fn reorder(&mut self, order: &[PaneId]); + fn claim_size(&mut self); + fn cancel_recovery(&mut self, pane: PaneId); fn drain_events(&mut self) -> Vec; - // Created / Output / Exited / Resized / SizeOwnership / Reordered } ``` -- `PtyBackend`(`backend/pty.rs`): portable-pty로 PTY를 만들고 reader 스레드가 출력·Exited를 채널로 푸시한다. 터미널 허브가 **구체 타입으로 소유**하며 `open_pane`으로 id를 직답받는다 — 만든 즉시 등록해야 하기 때문이다. -- `HubBackend`(`backend/hub.rs`): 데몬 소켓 위에 얹은 같은 trait. 저장소당 하나이고 attach 연결을 공유한다. 아무것도 소유하지 않고 요청한다. - -**소유하지 않는다는 사실이 trait을 네 군데 바꿨다.** - -1. **pane은 반환값이 아니라 이벤트로 온다.** id는 PTY가 실제로 사는 곳에서 나오고, 남이 연 pane도 같은 경로로 와야 한다. `create_pane`은 "요청"이고 `BackendEvent::Created`가 도착을 알린다. 이벤트가 `requested`를 실어 **내가 연 pane만** 포커스를 가져간다 — 어느 pane을 보고 있는지는 클라이언트 각자의 일이다. 제목도 같은 규칙으로 큐에 대기했다 도착 시 붙는다. -2. **크기는 이 클라이언트가 정하는 것이 아닐 수 있다**(아래 "PTY 크기" 참고). `Resized`를 따라가고, 소유하지 않으면 `resize`를 보내지 않는다. 로컬 `PtyBackend`는 성공 시 `Applied`, 원격 `HubBackend`는 서버 확인이 남았다는 `Pending`을 반환한다. 호출 실패는 `Result`로 전파되며 적용 성공처럼 에뮬레이터나 세션 상태에 기록하지 않는다. -3. **순서도 세션의 것이다.** `swap_active_with`는 `reorder` 요청이고, `panes`는 `Reordered`가 투영하는 서버 canonical order다. -4. VT 에뮬레이션은 어느 쪽이든 **클라이언트가 한다** — `PaneEmulator`가 소켓에서 온 바이트를 PTY에서 온 것과 똑같이 먹는다. 뷰어에서 xterm.js가 서 있는 자리와 같다. - -- **Pane 생명주기 단일 owner**: `drain_events`는 보고만 하고 제거하지 않는다. `Exited`를 받은 쪽이 `destroy_pane`을 호출해 PTY를 놓는다 — 클라이언트에서는 `TerminalState::poll`, 허브에서는 워커 루프다. 허브가 그것을 빼먹어 스스로 끝난 pane의 master fd가 샜다(캡은 live pane만 세므로 열고 끝내기를 반복하면 무한히 쌓인다). -- **닫기와 순서도 요청이다.** `close_active`는 pane을 그 자리에서 지우지 않고 `Exited`를 기다린다 — 세션이 실행하지 않은 닫기(커맨드 큐가 꽉 찬 경우)가 있으면 프로세스는 살아 있는데 이 클라이언트만 그 pane을 영영 못 보게 된다. 남의 클라이언트가 닫은 pane이 오는 경로와 같다. -- **세션이 시작 터미널의 이름을 준다.** `[[startup_command]] name`(없으면 커맨드 텍스트)이 `Created`에 실려 모든 클라이언트가 같은 이름을 쓴다. 클라이언트가 직접 연 pane은 이름 없이 오고, 어느 쪽이든 프로그램의 OSC 0/2가 나중에 덮어쓴다 — 그 덮어쓰기도 세션이 기억하므로 나중에 붙는 클라이언트가 같은 이름을 본다(아래 "붙는 클라이언트에게는 기록이 아니라 상태를 준다" 참고). - -## 세션 공유 (데몬 ↔ 클라이언트) - -무엇이 공유이고 무엇이 클라이언트별인지가 이 앱의 중심 결정이다. 전부 공유하면 브라우저에서 커서를 내릴 때 TUI 커서도 내려가 "디스플레이별 렌더링"이 의미를 잃고, 전부 로컬이면 같은 세션에 붙은 두 화면이 서로 다른 것을 보여준다. - -- **공유(데몬 소유)**: 저장소 집합과 순서, **활성 프로젝트**, 터미널 pane 집합·내용·순서·크기, 그리고 **accent** -- **뷰어 안에서만 공유(브라우저 간, TUI와는 공유 안 함)**: 사이드바 폭(`sidebar_width`), 터미널 패널 높이(`upper_pct`), 그리고 **프로젝트별 마지막 뷰**(`views` — 탭, 열려 있던 파일, 트리 펼침). 모두 `viewer.json`에 살지만 attach한 TUI는 읽지 않는다 — 폭은 TUI에 대응 값이 없어서, 높이는 대응 값(`config.layout.upper_pct`)이 있어도 공유가 틀린 답이어서, 마지막 뷰는 TUI가 **같은 것을 자기 파일에 이미 들고 있고 그 파일의 주인이 TUI라서**다([web.md](web.md)). -- **클라이언트별**: 커서·스크롤 위치, 포커스, fullscreen, 검색 텍스트 - -**accent는 원래 클라이언트별이었다.** 뒤집은 이유는 한 세션에 표면이 여럿이라는 사실이 그 편의보다 무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느 쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느 프로젝트인가"는 탭 이름과 활성 탭 강조가 이미 답한다. 값은 `viewer.json` 하나에 살고 (`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은 그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다. - -### Catalog는 membership과 runtime을 분리한다 (`session/catalog/`) - -저장소 집합을 결정하는 순수 상태(`CatalogMembership`)와 실제 status worker·terminal hub를 소유하는 상태(`CatalogRuntime`)는 별개다. membership은 base·browser-added·hidden·order의 합집합과 재사용하지 않는 id만 계산하고, runtime은 그 결과를 reconcile해 같은 path의 `Arc`를 그대로 보존한다. 따라서 무관한 탭 변경은 기존 runtime과 SSE subscriber를 교체하지 않는다. - -둘 사이 변경은 `Catalog` façade의 transaction 하나로 직렬화한다. config table 교체도 같은 transaction을 써서 동시에 열린 저장소는 교체 시점의 fan-out에 포함되거나 새 table로 spawn되는 둘 중 하나이며, 어느 쪽에도 속하지 않는 틈이 없다. reconcile은 먼저 새 runtime snapshot을 설치하고 retired entry를 값으로 돌려준다. worker `stop`과 join은 membership·runtime·transaction lock을 모두 놓은 뒤 실행한다 — 닫히는 저장소 하나의 종료 지연이 조회나 다음 catalog mutation을 막지 않게 하기 위해서다. - -### 데몬이 세션을 감시한다 (`daemon/watch.rs`) - -세션에는 문이 둘이다 — 브라우저의 HTTP 핸들러와 attach 소켓 — 그래서 브라우저에서 연 저장소는 attach 소켓의 아무것도 깨우지 않는다. watcher 스레드가 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 브로드캐스트한다. **알림(callback)이 아니라 관측인 이유**: 알림은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 "브라우저 변경이 TUI에 안 닿는" 버그로 다시 나타난다. 그래서 브로드캐스트하는 곳이 하나이고, 새로 생긴 저장소의 터미널을 모든 클라이언트에 구독시키는 것도 여기다 — 소켓을 읽는 스레드는 `read`에 막혀 있어 할 수 없다. attach 클라이언트의 요청은 watcher를 **즉시 깨우므로**(`Nudge`) 키 입력이 폴링 간격을 기다리지 않는다. - -**세트를 보내는 곳도 watcher 하나다.** 붙는 클라이언트도, 세트를 직접 물어본(`ListRepos`) 클라이언트도 자기가 보내지 않고 "아직 못 받았다"고 등록만 하고 watcher를 깨운다 (`clients.rs`의 `owed_set`). 한 큐에 생산자가 하나면 **프레임 순서가 곧 상태가 바뀐 순서**이기 때문이다. 전에는 attach 스레드와 watcher가 각자 보냈고, 둘 사이에 변경이 끼면 갓 붙은 클라이언트가 다른 모두가 떠난 상태에 남았다(watcher는 이미 "모두에게 알렸다"고 기록했다). 순서를 락으로 맞추는 대신 경쟁을 없애는 쪽이며, 뷰어의 preference 쓰기(`serialWrite.ts`)와 탭 순서 변경이 이미 같은 결론에 도달해 있다. 그래서 watcher를 띄우지 못하면 데몬은 **시작하지 않는다**(`serve::start`). - -### PTY 크기는 한 클라이언트가 정한다 - -PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이다 — 자식은 들은 폭에 맞춰 그리고, alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은 없다. 그래서 tmux의 `window-size latest`와 같은 모델을 쓴다: **뷰어의 도착이 곧 소유권 이전**, 이미 붙어 있으면 `claim_size`로 명시적 탈취(TUI ` z`, 뷰어의 "fit to this screen" 버튼), 소유자가 떠나면 남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지. - -- **소유권은 hub별이 아니라 세션 하나가 갖는다**(`session/size_owner.rs`). 어느 repo가 앞에 있는지는 세션 공유라 "이 세션은 어느 화면에 맞춰져 있나"는 질문이 하나다. hub마다 따로 답하던 때는 탭을 옮길 때마다 붙어 있는 모든 페이지가 동시에 재접속해 소유권이 **핸드셰이크가 늦게 끝난 쪽**으로 갔다. -- **뷰어는 커넥션이 아니다.** `접속 = 소유자 도착`은 소켓이 열렸다는 사실에서 의도를 읽어내는 것인데, 소켓은 사람이 앉는 것 말고도 열린다: repo 전환, 새로고침, 네트워크 끊김. 그래서 뷰어는 자기 이름을 대고(`ViewerId` — 브라우저는 탭당 id, attach한 TUI는 데몬 client id 하나로 모든 repo 구독을 묶는다) **방금 도착했는지를 직접 말한다**. 브라우저는 `sessionStorage`에 탭당 id를 두고(`lib/viewerId.ts`) `/ws/term`에 `viewer=`로 실어 보내며, 페이지가 처음 뜨는 한 번만 `claim=1`을 붙인다. `localStorage`가 아닌 이유는 그것이 탭별이 아니어서 한 브라우저의 두 탭이 한 뷰어가 되기 때문이다. `viewer=`가 없거나 형식이 어긋나면 서버가 일회용 id를 발급한다 — 거부가 아니라 이름을 대기 전의 동작으로 강등된다. -- **해제에는 유예가 있다**(`RELEASE_GRACE`, 2초). repo를 옮기면 소켓 하나가 닫히고 다른 하나가 열리는데, 그 사이의 공백은 부재가 아니다. 유예를 끝내는 것은 hub worker의 tick(`settle`)이다 — 볼 사람이 있으려면 hub가 돌고 있어야 하므로 전용 타이머가 필요 없다. -- **주인 없음은 빈 세션의 상태다.** 아무도 없을 때만 소유자가 없고, 뷰어가 하나라도 있으면 그중 하나가 갖는다. 그래서 주인 없는 상태에 커넥션이 붙으면 도착이 아니어도 그것이 가져간다 — 밀려날 사람이 없으니 "재접속은 남의 화면을 뺏지 않는다"는 조심성이 지킬 것이 없다. 이것이 없을 때 폰은 깰 때마다 관전자로 돌아왔다: 잠들면 페이지가 얼어 소켓이 죽고, 유예가 지나 소유권이 풀리고, 재접속은 도착이 아니므로 아무도 그것을 집지 않았다. 그 상태의 페이지는 떠난 화면의 크기로 pane을 그리고 fit 버튼을 띄운다. -- **소유권이 움직이면 로그가 남는다**(`size_owner_audit.rs`, INFO). 뷰어의 접속·해제 (`viewer connection joined` / `left`)와 소유권 이전(`terminal sizing moved`)을 이유와 함께 남긴다 — `reason`은 `a viewer arrived`, `a viewer asked`, `nobody owned it`, `the owner stayed gone` 넷 중 하나다. 이 전이는 붙어 있는 모든 클라이언트의 렌더를 바꾸는데, 클라이언트가 볼 수 없는 이유로도 일어난다(마지막 커넥션이 끊김, worker tick에서 유예 만료). 기록이 없으면 나중에 읽을 것이 증상뿐이다. -- 비소유자의 resize는 버려지고 **실제 적용된 크기가 브로드캐스트된다** — 관전자의 에뮬레이터도 자식이 감는 곳에서 감아야 하기 때문이다. 소유자는 `desired`(현재 레이아웃), `pending`(마지막 전송과 시각), `confirmed`(`Resized`로 확인한 실제 크기)를 분리한다. 늦은 이전 ACK가 에뮬레이터를 과거 폭으로 돌려도 `desired != confirmed`가 남아 최종 폭을 다시 요청하며, ACK가 오지 않으면 100 ms 뒤 재시도한다. 서버는 이미 같은 크기인 재시도에도 `Resized`를 답한다. -- **resize는 일반 terminal command queue에 넣지 않는다.** 입력과 create/close가 쓰는 bounded queue가 가득 차도 창 드래그의 마지막 폭은 잃으면 안 되므로, hub가 connection·pane별 최신 값만 별도 보관한다. worker는 일반 command를 64개 처리할 때마다 이를 합성 처리해 지속적인 입력에도 resize가 굶지 않으며, 연결이 끝나면 그 connection의 보류 값을 제거해 재접속 churn에도 저장량을 붙은 connection·pane 수 안에 묶는다. 중간 폭은 버려도 되지만 마지막 폭은 반드시 한 번 적용을 시도한다. `portable-pty`/ConPTY/TIOCSWINSZ resize가 실패하면 pane 상태와 mode emulator를 갱신하거나 `Resized`를 브로드캐스트하지 않는다. -- 입력마다 소유권을 옮기는 대안은 기각했다 — 폰으로 잠깐 확인하는 제일 가벼운 행동이 전체 repaint를 유발하는 제일 비싼 행동이 된다. 부수 효과로 **비소유 클라이언트가 곧 관전자**여서 별도 관전 모드가 필요 없고, 영역과 그리드가 다르면 렌더 경로가 clamp로 처리한다. - -### attach 터미널 수신은 유계 FIFO다 - -attach 소켓 하나가 모든 저장소의 터미널 출력을 받지만, 각 저장소의 에뮬레이터는 TUI 렌더 틱에서 자기 inbox를 소비한다(`daemon/terminal_link.rs`). 소켓 reader가 렌더보다 빠르다는 이유로 메모리를 무제한 늘리거나, 반대로 최신 출력만 남기는 것은 둘 다 허용되지 않는다. 터미널 바이트는 snapshot이 아니라 순서 있는 스트림이므로 중간 한 바이트를 버리면 뒤의 모든 escape sequence 해석이 틀어진다. - -- **drain은 저장소별 FIFO prefix만 꺼낸다.** 한 틱에 최대 64개 메시지와 256 KiB를 처리한다. 첫 메시지가 256 KiB보다 큰 replay frame이면 그것 하나는 꺼내야 head가 영원히 막히지 않는다. 예산 뒤의 메시지는 다음 틱에 남고, 모든 열린 저장소가 틱마다 각자 drain하므로 시끄러운 저장소 하나가 다른 탭의 입력·출력과 화면 갱신을 굶기지 않는다. -- **`Exited`도 같은 FIFO에 있다.** 그 앞에 도착한 `Output`이 byte 예산에서 잘리면 종료는 다음 틱까지 기다린다. `TerminalState::poll`은 `Exited`에서 pane과 에뮬레이터를 제거하므로 순서를 건너뛰면 마지막 출력이 사라진다. -- **연결 전체에 256 MiB output + 4,096 message 상한을 둔다.** output allowance는 데몬 쪽 연결 큐가 합법적으로 보낼 수 있는 256개의 1 MiB replay frame과 같은 크기이고, 별도 message 상한은 control event-only 폭주도 저장량을 무제한 키우지 못하게 한다. 저장소를 닫거나 drain하면 그 몫을 즉시 돌려준다. 다음 메시지 전체가 상한에 들어오지 않으면 일부를 잘라 넣거나 이후 메시지를 계속 받지 않고 reader가 연결을 끝낸다. 그러면 TUI는 연결 손실을 명시적으로 보고하고, 사용자가 다시 attach할 때 허브의 screen+since replay가 일관된 상태부터 복구한다. 손실된 구간 위에서 계속 그리는 경로는 없다. - -### 상태는 시간이 아니라 변화에 따라 읽는다 (`runtime/snapshot_watch.rs`) - -`git status` 한 번은 측정값으로 파일 260개 저장소에서 3 ms, 1만 개에서 23 ms, 5만 개에서 **129 ms**다. 1초마다 돌리면 아무 일도 없는 시간에도 그만큼을 태운다. 그래서 워크트리를 **재귀 감시**하고 변화가 있을 때만 읽는다. 옆의 트리 워처가 재귀 감시를 거부한 것과 다른 결론인데, 트리 뷰는 펼친 디렉토리만 필요해서 재귀가 낭비지만 status는 트리 전체가 대상이라 더 작은 감시 집합이 없다. 남는 위험(리눅스 inotify 디스크립터 소진)은 **설치 실패 시 예전의 1초 폴링으로 폴백**해서 받는다. 이 폴백은 **끈적하다** — 실패 원인(watch 상한, 권한)은 1초 뒤에 달라지지 않으므로, 재시도는 아무도 안 보던 저장소를 다시 볼 때만 일어난다. +`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`가 담당한다. -세 가지 상한이 이것을 안전하게 만든다: +세션 상한은 repository당 PTY 8개, pane 크기 1–500행 × 1–1100열, pane당 reconnect scrollback 256 KiB다. 명령 queue가 가득 찼다는 이유로 close/resize의 성공을 가정하지 않는다. -- **읽기 간격 하한 1초** — 이벤트가 폭주해도 비용이 정확히 예전 폴링과 같고 절대 그보다 크지 않다. -- **10초 상한** — 이벤트를 놓쳤거나 트리 일부에만 감시가 걸렸을 때의 안전망. 감시가 아예 없으면 이 값은 쓰이지 않고 1초 폴링이 된다. -- **git이 무시하는 경로는 읽지 않는다** — 빌드 산출물은 워크트리에서 가장 시끄럽고 status에 나타날 수 없는 유일한 것이다. `-f`로 추가된, 무시 디렉토리 안의 추적 파일이 이것이 잘못 건너뛰는 유일한 경우이고 10초 상한이 잡는다. +## Shared state -**아무도 안 보는 저장소는 걷지도 감시하지도 않는다**(`SnapshotChannel::watch`). 데몬이 여는 워커는 **처음부터 잠든 채로 시작한다**(`spawn_asleep`) — 깨워서 만든 뒤 끄면 워커가 그 사이에 한 번 읽고, 그 낡은 값이 나중의 더 새로운 읽기 뒤에 발행된다. 워커는 순회를 **끝낸 뒤에도** awake를 한 번 더 보고 잠들었으면 결과를 넘기지 않는다. 첫 구독자가 오면 다시 켜고 **그 자리에서 한 번 읽어** 답한다: 꺼져 있는 동안의 `latest`는 마지막 클라이언트가 떠날 때의 상태이고, 다음 날 아침에 연 페이지에는 그것이 낡은 값이 아니라 틀린 화면이다. `/api/status`도 같다. 이 켜고 끄기는 **구독자 목록 락을 잡은 채로** 결정한다 — 세었다가 놓고 등록하면 그 틈에 마지막 클라이언트가 떠나며 읽기를 꺼버려, 구독자가 붙어 있는데 아무도 다시 켜지 않는 상태가 남는다. +세션이 공유하는 것은 repository membership/order, active repository, pane 집합·내용·order·title·확정된 size, accent다. cursor, scroll, focus, fullscreen, search와 TUI의 `Workspace` view state는 client-local이다. viewer는 `viewer.json`에서 sidebar width·`upper_pct`·project별 last view/maximize만 브라우저 간 공유하며 TUI의 workspace 파일과 합치지 않는다. -- **남은 한계**: 워커가 큐에 넣은 읽기와 구독 시점의 즉시 읽기가 겹치면 오래된 쪽이 뒤에 발행될 수 있다. 다음 변화나 10초 안전망이 바로잡는다. 근본 해결(읽기마다 시각을 실어 발행 순서를 읽은 순서로 강제)은 `SnapshotMsg`가 TUI와 뷰어 양쪽에 걸쳐 있어 이 창의 크기에 비해 값이 크다. -- **git 디렉토리가 트리 밖에 있으면 그쪽도 감시한다.** `git worktree add`와 `--separate-git-dir`은 `.git`을 파일로 남긴다. 감시 대상은 `path()`가 아니라 **`commondir()`**인데, linked worktree의 `path()`에는 자기 index만 있고 ref는 본체 쪽에 있기 때문이다. 이 두 번째 감시는 저장소 핸들이 있어야 위치를 물을 수 있으므로 **읽기 뒤에**, 그리고 매 읽기마다 다시 확인한다(핸들은 주기적으로 다시 열린다). 감시를 새로 건 직후에는 읽기를 한 번 예약한다. 평범한 저장소는 두 번째 감시를 걸지 않는다 — 걸면 모든 이벤트가 두 번 온다. -- `objects`/`logs`/`*.lock` 필터는 **git 디렉토리 최상위에만** 적용한다. 서브모듈 이름은 트리에서의 경로라 슬래시를 포함해 `modules/foo/objects/HEAD`를 어떤 방법으로도 구분할 수 없다. 그래서 판단하지 않고 읽는다: 잘못 거르면 아무도 못 보는 변경이 생기고, 다 통과시켜도 서브모듈 fetch 중 초당 한 번 더 걷는 것이 전부다. -- **macOS는 이벤트 경로를 심링크 해석해서 준다**(`/var/...` → `/private/var/...`). 감시 디렉토리 경로를 canonical 형태와 원래 형태 양쪽으로 들고 비교한다 — 이걸 틀리면 정확성은 유지되지만 **ignore 필터가 조용히 통째로 무력화된다**. -- **이벤트 큐는 한 번에 비운다.** 읽기 한 번(5만 파일 129 ms) 동안 빌드는 수천 개의 이벤트를 쌓는데, 하나씩 소비하면 뒤에 도착한 **종료 신호도 그 뒤에서 기다린다**(`Drop`의 join은 5 ms 상한이라 그대로 detach로 떨어진다). 깨어난 김에 `try_recv`로 전부 받고, 이미 읽기가 예약된 상태(`changed`)면 경로마다 ignore 여부를 되묻지 않는다. +### Catalog transaction -### 스크롤백과 재접속 +`CatalogMembership`은 base config, browser-added path, hidden path와 explicit order의 순수 합집합을 opaque id와 함께 계산한다. `CatalogRuntime`은 그 결과를 reconcile해 같은 path의 `Arc`를 유지하고 새 entry에만 status runtime과 terminal hub를 만든다. membership·runtime·config table 변경은 catalog façade transaction으로 직렬화하며, 교체된 entry의 worker stop/join은 모든 catalog lock을 놓은 뒤 수행한다. -**스크롤백 깊이는 두 상한이 만나는 자리다** — 허브는 pane당 바이트 링(256 KiB), 클라이언트는 줄(1000)로 센다. 평범한 출력에서는 클라이언트의 줄 상한이 먼저 차지만, **줄당 ~262바이트를 넘으면 리플레이가 줄 상한을 못 채운다**(토큰마다 색을 바꾸는 하이라이팅이 거기에 닿는다). 그 지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 텍스트가 아니라 repaint 시퀀스이고, 상한은 저장소×pane마다 지불된다. +저장소 path는 catalog 경계에서 canonicalize한다. 같은 worktree의 다른 표기나 trailing separator는 중복 project가 되지 않는다. session open은 canonical path를 active preference로 기록하고, close는 현재 focus를 확인한 뒤 successor를 기록하되 동시에 일어난 다른 focus를 덮지 않는다. -**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`session/terminal/hub_modes.rs`, `runtime/emulator/{modes,snapshot}.rs`). 바이트 링은 역사이지 스냅샷이 아니어서, 프로그램이 시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate screen, 마우스 리포팅, bracketed paste, DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이언트는 **프로그램이 설정한 적 없는 터미널**이 된다(스크롤·클릭 죽음, 화살표 인코딩 불일치, 붙여넣기 깨짐). +### Session watcher -- 허브가 pane당 에뮬레이터를 돌려 현재 모드를 `PaneState`에 적고(처음엔 모드 확인용이었지만 지금은 스냅샷이 그리드도 읽는다 — 아래), `connect`가 history보다 **먼저** `PaneModes::prelude`를 보낸다. 프렐류드는 12개 모드를 h/l로 **전부 명시**한다 — 받는 쪽은 xterm.js고 그 기본값은 이 에뮬레이터의 것이 아니다(`1007`이 실제로 다르다). -- **pane 제목도 같은 이유로 여기서 따라간다.** OSC 0/2는 프로그램이 시작할 때 한 번 보내고 마는 것이라 모드와 성질이 똑같다. 클라이언트마다 각자 읽게 두었더니 그 바이트가 지나갈 때 붙어 있던 화면만 이름을 알았고, 나중에 온 페이지도 재접속한 페이지도 위치 라벨(`term 1`)로 돌아갔다 — 에이전트를 띄워 둔 pane이 세션 내내 그렇게 보였다. 이제 허브가 최신 제목을 `PaneState.title`에 적고 `Created`에 실어 보낸다. **붙어 있는 클라이언트에게 따로 알리지는 않는다** — 그들은 제목을 세팅한 바이트 자체를 받고 있고 각자 에뮬레이터가 그것을 읽는다. 자식이 고르는 문자열이므로 들어올 때 `MAX_PANE_TITLE_CHARS`로 자른다. -- **alternate screen pane은 링을 replay하지 않는다**: 그 바이트는 이 클라이언트에 없는 화면에 대한 셀 갱신이고, 전사는 프로그램 자신의 메모리에 있다. 대신 **허브가 화면을 갖고 있다가 그것을 준다**. 모드 추적용으로 이미 pane마다 돌고 있는 에뮬레이터가 그 셀 갱신이 만들어낸 화면을 들고 있으므로, `PaneEmulator::screen_snapshot`이 그것을 다시 바이트로 쓴다 — 행마다 `CUP`, 속성 런마다 reset으로 시작하는 `SGR` 하나, 그리고 프로그램이 남긴 pen과 커서. **절대 repaint**라서 받는 쪽의 커서·속성이 무엇이었든 결과가 같다. VS Code의 pty host가 headless xterm.js + SerializeAddon으로 하는 것과 같은 모델이고, tmux·mosh도 서버가 화면을 소유한다. -- **normal screen도 화면은 스냅샷이 진다: 링 + `covered` + `normal_screen`.** 링이 화면과 역사를 겸하던 시절의 구멍: 제자리 repaint만 하는 프로그램(Claude Code의 입력 박스, 스피너)은 **스크롤 없이** 링을 회전시키므로, 오래 방치한 pane은 화면 상단을 그린 바이트가 밀려나고 재접속한 클라이언트는 하단 박스만 남은 빈 화면을 봤다. 이제 normal replay는 `링[..covered]`(역사) → `normal_screen`(covered 시점 화면의 절대 repaint) → `링[covered..]`(스냅샷 이후 전부) 순서다. 스냅샷의 `2J`가 잘린 역사가 남긴 viewport 잔해를 지우고 화면을 온전히 다시 그리며, 스크롤백으로 넘어간 줄은 `2J`가 건드리지 않아 역사도 보존된다. eviction은 `covered` 앞에서만 일어난다 — 마크 뒤 tail은 스냅샷 위에 얹혀야 할 바이트라 하나도 버릴 수 없고(alt `since`와 같은 규칙), tail이 상한을 넘으면 새 스냅샷을 떠 마크를 옮긴다. 갱신은 그때와 resize 때뿐이다: alt처럼 tick마다 갱신하지 않는 이유는, tail replay가 정확성을 이미 보장해서 스냅샷 비용이 링 한 바퀴(256 KiB)당 한 번이면 충분하기 때문이다. 역사를 링 대신 에뮬레이터에 들리는 안(줄 단위 history 직렬화)은 기각했다 — pane당 메모리가 수십 MB로 뛰고, Claude Code류의 전사는 어차피 프로그램 자신의 메모리에 있어 얻는 것이 없다. -- **스냅샷 앵커는 시퀀스가 닫힌 chunk 경계만 잡는다**(`runtime/emulator/boundary.rs`). PTY read는 임의 바이트 위치에서 끊기므로 chunk가 escape 시퀀스나 멀티바이트 문자 한가운데서 끝날 수 있는데, 거기에 스냅샷을 접합하면 재접속 클라이언트에게 시퀀스의 꼬리가 일반 입력으로 도착한다(`ESC [ 2`가 이음매 앞, 뒤에 온 `J`는 화면에 글자로 찍힌다). 에뮬레이터가 파서 상태 기계의 골격만 미러링해 "시퀀스가 열려 있는가"를 답하고, 열려 있으면 스냅샷을 다음 깨끗한 chunk로 미룬다 — crowded 신호는 chunk마다 반복되므로 미룬 스냅샷은 저절로 재시도된다. 미러는 정직하게만 답하고(파서가 시퀀스 중인데 경계라고 답하는 방향의 발산이 없다), 무한 미루기는 호출부가 막는다: 스냅샷을 기다리는 기록이 링 **두 바퀴**를 넘으면(desperate) 실제 시퀀스가 그만큼 열려 있을 리 없으므로 깨진 스트림으로 치고 이음매를 감수하며 스냅샷한다. DEC 2026 synchronized update 동안은 Processor가 바이트를 그리드에 적용하지 않고 버퍼링하므로, 시퀀스가 다 닫혀 있어도 `sync_bytes_count`가 0이 될 때까지 경계가 아니다 — 아니면 기록은 덮었다고 세는 바이트가 스냅샷에는 없다. desperate가 우회하는 것은 시퀀스 이음매뿐이고 이 sync 조건은 우회하지 못한다 — 이음매는 화면에 한 번 찍히는 잡음이지만 누락은 틀린 화면이며, sync는 ESU 아니면 Processor 자체 버퍼 상한(2 MiB)에서 반드시 끝나므로 우회 없이도 유계다. alt의 진입·tick·resize 갱신도 같은 게이트를 탄다. -- **프로그램에게는 아무것도 청구하지 않는다.** 예전에는 크기를 한 행 줄였다 되돌려 `SIGWINCH`로 다시 그리게 했는데, 그것은 부탁이고 부탁은 거절될 수 있다: 네트워크에 막힌 프로그램은 안 그리고, 요청은 유계 큐에 실려 가득 차면 버려지고, 재접속 폭풍을 막던 pane당 최소 간격은 요청을 **미루지 않고 버려서** 마지막으로 성공한 연결이 굶었다(1초 재연결 타이머 대 2초 간격 — 두 번째 시도가 항상 창 안에 들어온다). resize가 대신해 주지도 못한다. 재접속한 클라이언트의 레이아웃은 보통 끊기기 전과 같아서 resize를 아예 보내지 않는다([web.md](web.md)의 "PTY 크기는 확정된 값만 전달한다"). -- **이 전제를 잃어 실제 사고가 났다** — 예전에는 붙자마자 오는 resize에 repaint를 기대고 있었는데, 리로드 플리커를 없애려 같은 크기면 resize를 생략하면서 그 repaint가 사라졌다. 사용자가 깨진 화면에 누르는 복구 키가 `Ctrl+L`이고 fullscreen Claude Code는 그것을 2초 안에 두 번 받으면 `/clear`를 실행한다 — 대화가 연달아 지워졌다. 그때 들어온 repaint 요청 방식이 위의 구멍들을 남겼고, 허브가 화면을 직접 갖는 것이 그 구멍을 닫는다. -- **에뮬레이터가 셀을 읽으므로 resize를 따라가야 한다**(`hub_layout::resize_pane`). 그리드가 틀린 폭이면 자식이 감지 않는 곳에서 감아 다른 클라이언트와 다른 화면을 준다. 예전에는 모드만 봤으므로 그리드가 파서의 스크래치였고 resize를 따르지 않았다. resize는 지금 pane이 올라가 있는 화면의 스냅샷도 reflow된 그리드로 갱신한다 — 자식이 `SIGWINCH`에 안 그려도 그 사이에 붙는 클라이언트가 옛 크기의 화면을 받지 않게. 반대쪽 기록(alt 밑에 동결된 normal 스냅샷)은 에뮬레이터의 비활성 그리드라 읽을 수 없어 옛 크기로 남는다 — 복귀 후의 출력이 tail로 그 위에 얹히고, 다음 스냅샷 갱신이 마저 고친다. -- **꺼내 둔 resize도 연결 수명을 넘지 못한다.** latest-value map에서 worker가 값을 꺼낸 직후 소유자가 떠날 수 있으므로 요청은 원래 connection id를 함께 보존한다. 적용 시 hub state를 먼저 잠가 같은 client/connection 등록인지 확인하고, 그 lock 아래 session ownership을 다시 검사한다 (`state → ownership`, `connect`와 같은 순서). 따라서 disconnect나 소유권 이전이 먼저 완료되면 이미 꺼낸 옛 요청도 새 소유자의 PTY에 적용하거나 ACK하지 않는다. -- **화면 하나로는 부족하다: `screen` + `since`.** 스냅샷은 worker tick마다 갱신되므로 chunk가 broadcast된 뒤 그것이 스냅샷에 반영되기 전에 클라이언트가 붙을 수 있다. 그래서 스냅샷 이후 broadcast된 바이트를 옆에 함께 들고, replay는 `screen` 다음에 `since`를 보낸다 — 둘을 합치면 이미 붙어 있는 클라이언트가 본 것과 정확히 같다. **`since`에서 바이트를 버리지 않는다**(터미널 바이트는 건너뛸 수 없다). 상한을 넘으면 새 스냅샷을 떠서 비운다. 버퍼를 전환하는 chunk는 끝이 깨끗하면 tick을 기다리지 않고 즉시 스냅샷한다 — 전환 전 화면 위에 전환 후 바이트가 얹히는 창을 남기지 않기 위해서다. 시퀀스 중간에 잘린 전환 chunk는 `since`로 들어가고 스트림이 닫힌 뒤의 갱신이 화면을 가져간다 — 그때까지 붙는 클라이언트는 그 chunk를 raw로 받아, 전환 전 텍스트가 잠깐 alt 버퍼에 찍힌다(다음 paint가 덮는다). 열린 시퀀스에 접합하지 않는 값이다. -- **alternate screen 동안 normal 기록은 동결된다.** 그 바이트는 링에 쓸모없고, 프로그램이 전환한 시점의 기록이 곧 그가 돌아갈 normal screen이다. 그래서 alt pane의 replay는 `1049l` + normal 기록(링[..covered] + `normal_screen` + 링[covered..]) → prelude(`1049h`) → `screen` + `since` 순서다. 복귀(`1049l`) 때도 동결된 normal 기록은 그대로 유효하다 — 에뮬레이터의 normal 그리드는 alt 그리기에 건드려지지 않았다. 이것이 없을 때는 full-screen 프로그램을 종료하면 그 도중에 붙은 클라이언트가 빈 화면을 봤다. 남은 한계: 전환이 일어난 **그 chunk**의 전환 이전 텍스트는 링에 들어가지 않는다(chunk 단위로 기록하므로). 같은 뿌리의 한계 하나 더: 분류가 chunk 처리 **후의** 모드를 읽으므로, synchronized update가 chunk 경계에 걸친 채 화면 전환을 품으면 BSU가 든 chunk와 ESU가 든 chunk가 다른 기록으로 갈라져, 재접속 replay에 ESU 없는 BSU가 남을 수 있다 — 그 클라이언트는 자기 sync 타임아웃까지 화면을 들고 있다가 다음 출력에 회복한다. 근본 해결은 sync가 잡고 있는 바이트를 기록에서도 보류했다가 확정된 모드로 분류하는 것인데, 필요해지면 그때 올린다. -- **replay는 1 MiB 프레임으로 쪼개 보낸다**(`REPLAY_CHUNK_BYTES`). 클라이언트는 받은 바이트를 파서에 이어 붙일 뿐이고 그 파서는 write 경계를 넘어 상태를 유지하므로, 프레임 경계는 아무 의미가 없다 — 쪼개는 것이 공짜다. 반면 프레임 하나에는 상한이 있다: 데몬 소켓은 4 MiB를 넘는 payload를 거부하고(`daemon/frame.rs`), 링과 달리 화면은 **pane 면적에 비례해** 커져서 큰 pane을 셀마다 다른 색으로 덮으면(truecolor 이미지 렌더러가 그렇게 한다) 수 MB에 이른다. 통째로 보내면 attach 연결이 끊기고, 재접속해도 같은 화면을 다시 보내므로 반복해서 끊긴다. 화면을 하나의 쪼갤 수 없는 메시지로 보내는 구현은 없다 — VS Code의 replay는 엔트리 배열이고, tmux는 넘겨받은 파일 디스크립터에 직접 쓰고, mosh의 데이터그램은 화면을 담을 수조차 없다. 1 MiB는 상한에 여유를 두면서, 이 허브가 허용하는 가장 큰 pane들의 replay 전체가 `CLIENT_QUEUE_DEPTH` 안에 들어오게 한다 — 그것이 클라이언트 등록 **전에** 큐에 밀어 넣어도 안전한 이유다(그 큐에 쓰는 것이 아직 아무도 없다). -- **스냅샷이 나르지 않는 것**: wrap 기록(`WRAPLINE`, 마지막 칼럼에서 밀린 wide char의 `LEADING_WIDE_CHAR_SPACER`) — 절대 repaint는 행을 독립적으로 놓으므로 감긴 행이 두 행으로 도착하고 이후 resize에서 다르게 reflow된다. 지금 그 차이를 읽는 것은 없다(alt 프로그램은 resize에 다시 그리고, normal pane의 **역사**는 여전히 링으로 replay되어 wrap이 보존된다 — 스냅샷이 대신하는 것은 화면뿐이다). underline 색, 하이퍼링크(OSC 8), 스크롤 리전(DECSTBM)도 나르지 않는다. +브라우저 HTTP와 attach socket은 서로 다른 요청 경로이므로 repository set·active·accent의 변경을 `daemon/watch.rs`가 관측한다. watcher는 150 ms tick 또는 attach mutation의 nudge 뒤에 session을 다시 읽고, 마지막으로 보낸 값과 다를 때만 broadcast한다. repository set을 보내는 producer는 watcher 하나뿐이며, newly served repository의 terminal subscription도 set을 broadcast하기 전에 연결한다. watcher를 시작하지 못한 데몬은 실행하지 않는다. -**입력의 출처를 기록한다**(`session/terminal/hub_diag.rs`, `session.rs`, `viewer-ui/src/lib/clearKeyProbe.ts`). 특정 사건 때문에 존재하는 계측이다 — 5초 사이에 대화가 14번 지워졌는데 `0x0c`가 30번쯤 기계적 간격으로 들어왔다는 뜻이고, **무엇이 보냈는지 알 수 없었다**. nightcrow가 합성하는 입력은 스크롤·마우스 리포트와 plugin의 `continue`뿐이고 후자는 그 자리에서 로그를 남기므로, 남는 것은 클라이언트의 입력이다. +## PTY size ownership -- **도착 기록** — 허브가 `0x0c`가 실린 입력 프레임마다 pane·client id·개수·동승 바이트 수·직전 프레임과의 간격·연속 구간 누계를 남긴다. 키보드에서 온 `^L`은 혼자 오고 paste나 스크립트가 쓴 블록은 그렇지 않으므로 **동승 바이트 수와 간격만으로 모양이 갈린다**. 한 구간에서 40줄까지만 쓰고 나머지는 세기만 한다 — 눌린 채 반복되는 키는 초당 수십 번이라 로그가 스스로를 밀어낸다. -- **출처 지문** — 브라우저가 `0x0c`를 보낼 때 그것을 만든 keydown의 `isTrusted`·`repeat`·`code`·경과 ms를 함께 보고한다. `isTrusted:false`는 **확장 확정**, `true`+`repeat:true`는 물리적 키 반복, keydown 없이 온 바이트는 paste·IME·직접 주입이다. -- **입력 내용은 어느 쪽도 기록하지 않는다** — 세는 것과 타이밍뿐이다. 보고는 클라이언트가 하는 말이므로 분당 상한을 두고, `code`는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을 위조할 수 있다). 원인이 특정되면 이 계측은 지운다. +PTY child가 그린 폭은 alternate-screen 화면을 사후에 재배치할 수 없는 계약이므로 세션 전체에 한 owner만 둔다. viewer의 명시적 arrival 또는 `claim_size`가 owner가 되고, owner가 떠난 뒤 2초 `RELEASE_GRACE`가 지나면 남은 viewer로 넘긴다. 연결 재접속·repository 전환은 viewer arrival과 구별한다. 아무 viewer도 없으면 owner 없음과 마지막 확정 크기를 유지한다. -## Config Reload (`session/reload.rs`) +비소유자의 resize는 버리며 실제 PTY 적용에 성공한 `Resized`만 broadcast한다. owner는 desired/pending/confirmed size를 분리하고 늦은 확인이 과거 크기여도 desired와 다르면 재요청한다. resize는 일반 input queue와 별도의 connection·pane별 latest-value queue에서 처리해 queue 포화에도 마지막 폭을 잃지 않는다. disconnect와 resize의 경합에서는 connection 등록과 ownership을 다시 확인한 요청만 적용한다. -`config.toml`을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가 작업 중이던 것까지. 그래서 **두 테이블만 다시 읽는다.** 무엇이 즉시 닿고 무엇이 안 닿는지는 "그 값을 이미 무엇에 썼는가"가 정한다. +## Status snapshot -- **`[[plugin]]` — 열려 있는 모든 프로젝트에 즉시.** plugin은 pane이 아니라 자식 프로세스라 교체 비용이 세션에 없다. hub별로 diff한다(`terminal/hub_reload.rs`): 새로 원하게 된 것을 띄우고, 아닌 것을 멈추고, **`command`/`args`/`env`가 바뀐 것만** 프로세스를 갈아치운다. `allowed_resume_flags`·`watch_on_signal`만 바뀌면 살아 있는 자식을 건드리지 않는데, 그 둘은 판정마다 이쪽에서 읽는 값이고 plugin은 몇 시간짜리 대기 중일 수 있기 때문이다. -- **`[[startup_command]]` — 이후에 여는 프로젝트부터.** hub는 startup pane을 자기 수명에 **딱 한 번** 만든다(`started: AtomicBool`). 이미 열린 프로젝트가 그 목록에 쓴 pane은 살아 있는 자식이라 파일 편집을 근거로 교체할 수 있는 대상이 아니다. Catalog의 목록만 바뀌고 (`catalog/config_tables.rs`) 그 뒤 runtime reconcile이 띄우는 hub가 새 목록을 받는다. -- **나머지는 재시작이 필요하다**: `[web_viewer]`(리스너가 이미 바인드됨), `[log]`, 그리고 클라이언트 소유인 `[layout]`·`[input]`·`[tree]`·`[mouse]`. +`SnapshotChannel`은 subscriber가 있을 때만 status를 읽고 filesystem을 감시한다. 구독자가 없는 `/api/status`의 on-demand 요청은 한 번 읽을 수 있다. recursive worktree watcher와 별도 git directory(`git worktree`/`separate-git-dir`) watcher를 사용하며, Linux watcher 한도나 권한 때문에 설치하지 못하면 1초 timer로 폴백한다. 정상 watcher는 읽기 사이 최소 1초, 놓친 event를 보완하는 최대 10초 간격을 지킨다. git이 무시하는 path는 event 필터에서 읽기를 깨우지 않는다. -**전송 계층에 독립적이다.** `session.rs`와 같은 자리에 같은 이유로 둔다 — 브라우저는 `POST /api/reload`, attach한 TUI는 `ClientMessage::ReloadConfig`로 닿고, 둘이 **같은 상태 변경**에 착지해야 한다. 여기서 인증하지 않는 것도 `session.rs`와 같다(누가 물어볼 수 있는지는 각 전송이 정한다). 요청은 **아무것도 실어 나르지 않는다** — 파일 자체가 요청이다. 내용을 실어 보내게 하면 클라이언트가 지어낸 설정으로 세션을 재구성할 수 있다. +sleep에서 awake로 전환할 때 즉시 한 번 읽고, awake가 꺼진 뒤에는 진행 중인 stale 결과를 publish하지 않는다. watcher event backlog는 한 번에 흡수한다. linked worktree의 git directory와 macOS/Windows가 보고하는 canonical path 차이를 함께 처리한다. `SnapshotChannel` drop은 stop signal 후 bounded `try_timed_join`한다. -**절반만 적용되지 않는다.** 파일 전체를 파싱·검증한 뒤에야 아무것이든 건드린다. **파일이 사라진 경우는 거부한다** — 시작 시에는 "아직 설정 없음"이 정상이지만 reload 시점에는 실수이고, 기본값으로 읽으면 파일을 지우고 reload하는 것이 모든 plugin을 조용히 멈추는 경로가 된다. `--exec` pane은 파일에 없으므로 Catalog가 따로 기억해 다시 병합한다(`config::merge_startup_commands`). +status payload는 완전한 최신 그림이라 runtime fan-out에서 conflate할 수 있다. 반대로 terminal byte는 FIFO stream이라 drop/conflate하지 않는다. attach reader는 repository별 FIFO prefix만 tick당 최대 64 messages/256 KiB drain하며, connection inbox는 256 MiB 또는 4096 messages를 넘기지 않는다. 초과 연결은 끊고 client가 명시적으로 reconnect한다. -**hub에서 무엇이 plugin을 원하는지는 그 hub의 opt-in으로 판정한다** — 새 파일의 것이 아니다. 편집으로 추가된 `[[startup_command]]`는 이미 뜬 hub에 pane이 없으니 그것이 가리키는 plugin을 띄우면 영영 아무것도 받을 수 없는 자식이 된다. 반대로 **살아 있는 pane을 보고 있는 plugin은 아무것도 그것을 지명하지 않아도 유지한다**: 살아 있는 agent 터미널을 조용히 감시 해제하는 쪽이 더 나쁘다. 멈추라는 뜻은 `enabled = false`이고 그건 따른다. +## Terminal replay and reconnect -- **pane의 opt-in은 host가 없어도 기록한다**(`hub_plugins.rs`의 `intended`). 이것이 세션 중간에 plugin을 켰을 때 그것이 꺼져 있는 동안 만들어진 pane에 닿게 하는 유일한 경로다. 그 자체로는 아무 권한도 주지 않는다: pane에 실제로 작용하는 것은 `owners`뿐이다. reload로 멈춘 plugin은 pane을 놓아주되 opt-in은 남기므로 **끄고 다시 켜면 처음 켜는 것과 같은 자리에 착지한다**. -- **후계자가 뜨지 못하면 그 pane들도 놓아준다**(`Plugins::abandon`). 교체는 멈춘 plugin이 살아 있는 pane을 계속 붙잡는 유일한 경우인데 그 근거는 곧 후계자가 온다는 것뿐이다. spawn이 실패하면 host 없는 이름이 pane을 소유한 채 남고, 그 pane이 다음에 끝날 때 아무도 부탁할 수 없는 9일짜리 hold가 된다(`is_inert`인 hub는 만료 작업조차 돌지 않는다). -- **guard는 절대 재생성하지 않는다.** relaunch 예산은 pane의 token으로 키를 잡는데, 그것이 exit마다 relaunch로 답하는 plugin을 묶는 유일한 상한이다. reload마다 새 allowance를 발급하면 그 상한에 영영 닿지 않는다 — `take_over`가 spent budget을 그대로 두는 것과 같은 근거다. -- **relaunch hold는 그것을 쥐고 있던 자식과 함께 죽는다** — 교체든 정지든. 후계자는 **hub에 아직 남아 있는 pane만** 건네받는다(`start_host`가 `titles`로 걸러낸다). 그대로 두면 슬롯이 아무도 이행할 수 없는 9일 창을 끝까지 앉아 있는다. -- **plugin을 재시작하면 그 plugin이 진행 중이던 것은 사라진다.** 상태가 그 프로세스 안에 살기 때문이다 — `nightcrow-recovery`의 `panes: HashMap`은 메모리뿐이다. host가 대신 경고할 수 없다: **살아 있는** pane에 대한 대기는 plugin 안에만 있고 host의 `pending`에는 없다. 그래서 이 손실의 범위를 좁히는 것이 `spec_changed`의 진짜 값이다. -- **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의 저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다. -- **reload와 프로젝트 열기의 경합은 Catalog의 façade transaction이 막는다.** 테이블 교체와 "알려줄 저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다 (`set_config_tables`가 `Vec>`를 반환하는 이유). 없으면 같은 순간에 열린 저장소가 둘 사이로 빠져 열려 있는 내내 이전 `[[plugin]]` 테이블로 돈다. +hub emulator는 pane의 current terminal modes와 OSC title을 기억한다. 연결 시 mode prelude와 title을 replay하고, screen snapshot 뒤 snapshot 이후 byte를 담은 `since`를 보낸다. alternate screen은 current screen snapshot을, normal screen은 ring history와 normal snapshot 및 tail을 조합한다. snapshot boundary는 열린 escape/multibyte/synchronized-update sequence를 가르지 않으며, 경계가 오래 지연되면 bounded fallback을 사용한다. `screen`/`since` 어느 쪽도 중간 byte를 버리지 않는다. -**답은 물어본 클라이언트에게만 간다** — reload가 하는 일은 다른 클라이언트 화면에 아무것도 드러나지 않으므로, 전부에게 알리면 자기가 하지도 않았고 볼 수도 없는 일에 대한 알림이 된다. 브라우저에도 화면 변화가 없어 **toast가 피드백 전부**다. 문구는 서버가 만든다 (`ReloadReport::summary`) — 같은 reload에 대해 TUI notice와 브라우저 toast가 다른 말을 하지 않도록. **닿지 못한 저장소는 보고에 드러낸다**: 큐가 가득 찬 hub는 요청을 받지 못하는데, 막고 기다리면 그 하나 때문에 나머지가 전부 밀리므로 기다리지 않고 `ReloadReport::unreachable`로 세어 `(1 was too busy to be told)`로 덧붙인다. +replay는 1 MiB chunk로 분할하고 daemon frame payload는 4 MiB 이하로 제한한다. attach client의 terminal inbox가 overflow하면 일부 byte만 버리고 계속하지 않고 연결을 닫는다. 새 client가 받은 frame 순서는 `Created`/mode/zoom/replay 계약을 지키며, 재접속 후 client emulator는 같은 byte stream을 다시 적용한다. -## Worker Thread Lifecycle (의도된 비대칭) +## Config reload -완료 후 한 번 답하는 백그라운드 worker(`SnapshotChannel`, `CommitLogPagination`, `PtyPane`)는 모두 "receiver/owner를 먼저 drop → worker가 다음 send 실패로 종료"라는 공통 종료 신호를 쓰지만, **호출 지점이 hot path인지 quiescent moment인지에 따라 join 정책이 의도적으로 다르다.** 리뷰 시 이 비대칭을 깨뜨리지 말 것. +`POST /api/reload`와 attach의 reload request는 transport와 무관한 같은 operation을 호출한다. `config.toml` 전체를 parse/validate한 뒤에만 적용하며, 파일이 사라졌거나 잘못되면 session을 변경하지 않는다. `[[plugin]]` 변경은 열린 repository hub에 즉시 요청하고, `command`/`args`/`env` 변경 때만 child를 교체한다. `allowed_resume_flags`와 `watch_on_signal`은 다음 판정부터 읽는다. `[[startup_command]]` 변경은 이후 생성되는 hub에만 적용한다. web/listener·log·layout/input/tree/mouse 설정은 재시작 대상이다. -- **Hot path (UI 틱 안)**: `launch_commit_log_worker`는 이전 `JoinHandle`을 join 없이 drop한다. 매 prefetch마다 5 ms를 기다리면 스크롤이 jank해진다. worker 본체는 `tx.send` 1회 후 종료하므로 누적되지 않고, 받는 쪽(`page_rx`)을 먼저 drop했기 때문에 그 send는 즉시 실패한다. **timed-join을 여기 추가하지 말 것.** -- **Quiescent moment (Drop, repo switch, reply drain 직후)**: `cancel_commit_log_page_fetch`, `poll_commit_log_page_fetch`의 reply drain 분기, `Drop` impl은 모두 `try_timed_join`(~5 ms)을 쓴다. 사용자가 클릭한 시점이거나 worker가 이미 마지막 syscall에 도달한 시점이라 UX 손실 없이 OS 스레드를 즉시 회수한다. +reload lock은 concurrent reload를 직렬화하고, catalog transaction은 reload와 project open이 서로 다른 config table을 보는 틈을 막는다. hub queue가 가득 차 전달하지 못한 repository는 보고서의 `unreachable`로 표시하며, reload 결과는 요청한 client에만 반환한다. plugin reload가 기존 pane의 opt-in을 조용히 취소하거나 relaunch budget을 재생성하지 않는다. -`try_timed_join`은 `src/platform/threading.rs`에 공유 helper로 두고 snapshot/commit-log/PTY 세 곳에서 호출한다. 새 worker 패턴을 추가할 때도 같은 분기 기준으로 join 정책을 고른다. +## Worker lifecycle -`GitLoadWorker`는 예외적으로 프로젝트 수명 동안 살아 있는 conflated worker다. 아직 시작하지 않은 요청을 lane별 한 슬롯에 덮어써 10만 번의 연속 선택도 큐나 스레드를 10만 개 만들지 않는다. 따라서 reply receiver drop만으로는 요청을 기다리는 `Condvar`를 깨울 수 없어 `Drop`이 stop flag를 세우고 깨운 뒤 5ms 동안 완료를 기다린다. 실행 중인 libgit2 호출은 강제 중단하지 않으며 제한 안에 끝나지 않은 handle은 detach한다. 대신 thread 수명 전체에 process-wide permit을 먼저 발급해 열린 프로젝트 10개와 멈춘 libgit2 호출 8개를 합한 18개를 실제 thread/FD hard bound로 둔다. 별도의 공정한 FIFO admission이 process/동일-repo libgit2 동시 호출을 8/1로 제한한다. 취소된 ticket은 큐에서 빠지고, 같은 repo 때문에 막힌 ticket은 unrelated repo의 eligible ticket을 가로막지 않는다. 늦은 reply는 `(repo, generation)` guard가 버린다. 스레드 생성 실패는 pending request를 유지한 채 다음 submit 또는 reply poll에서 재시도하되 16 ms부터 두 배씩 늘려 최대 1초까지 기다린다. 첫 실패는 경고하고 같은 실패가 이어지면 경고도 30초에 한 번으로 제한하며, 생성에 성공하면 지연과 경고 제한을 모두 초기화한다. 예상하지 못한 worker 종료는 완료된 handle을 회수한 뒤 같은 방식으로 재시작한다. 개별 git load panic은 cache를 버리고 해당 request에 일반화된 error reply를 보내므로 worker와 이후 request는 계속 진행한다. +완료 후 한 번 답하는 worker는 receiver/owner를 먼저 drop해 종료시키고, hot UI path에서는 join하지 않으며 drop·repository switch·reply drain 같은 quiescent 시점에는 `platform::threading::try_timed_join`으로 회수한다. 수명 긴 `GitLoadWorker`는 lane별 pending을 하나로 합치고 stop flag/condvar로 종료한다. process-wide와 동일 repository git I/O permit, worker thread/FD hard bound를 유지하며 늦은 reply는 `(repository, generation)` guard가 버린다. 실행 중 libgit2 호출은 강제 중단하지 않고 제한을 넘긴 handle은 detach한다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/terminal.md b/docs/architecture/terminal.md index b4954f99..57aa980c 100644 --- a/docs/architecture/terminal.md +++ b/docs/architecture/terminal.md @@ -1,63 +1,39 @@ # Terminal Panel -하단 터미널 패널의 레이아웃(여러 pane 동시 렌더), pane당 VT 에뮬레이션, 그리고 스크롤·마우스 입력이 어느 pane의 어느 프로그램에게 어떤 모양으로 전달되는지를 다룬다. 관통하는 원칙 하나: **청구하지 않은 pane에는 한 바이트도 보내지 않는다** — 프로그램이 스스로 켠 모드만이 무엇을 보낼지 정한다. +하단 패널은 pane을 탭으로 교체하지 않고 visible window 안의 여러 PTY를 동시에 렌더한다. pane별 상태와 입력 대상은 안정적인 `PaneId`로 식별하며, 세션 hub의 pane 순서·내용·크기와 클라이언트의 화면 상태를 분리한다. -## Split-View Terminal Panel +## Split-view and sizing -하단 패널은 현재 *visible window* 안의 모든 pane을 탭 전환 없이 한꺼번에 그린다. 창 밖으로 스크롤된 pane의 PTY도 백그라운드에서 계속 돈다. +- 일반 모드는 최대 4개, fullscreen grid는 최대 8개, zoom은 1개 pane을 보인다. `visible_start`와 `active`가 정한 범위는 항상 active를 포함하도록 최소한만 재조정한다. pane 생성·포커스·swap·종료·복원 뒤에는 `sync_visible_window`를 호출한다. +- pane swap은 Vec 순서와 active index만 바꾸며 parser, scroll, prompt buffer, PTY는 `PaneId` keyed 상태로 유지한다. pane reorder는 세션 요청/이벤트로 확정되고 재시작 시 영속화하지 않는다. +- fullscreen은 `Off → Grid → Zoom → Off` 순환이다. pane 하나뿐이면 Zoom을 건너뛰고, 마지막 pane을 닫으면 Off로 돌아간다. 영속 상태는 fullscreen 여부만 보존한다. +- `split_pane_areas`가 pane 수별 grid를 계산하고, 단일 pane은 border 없는 경로를 사용한다. `visible_pane_cells`가 렌더와 resize·hit-test의 단일 기하 출처다. 원격 backend는 확인된 `Resized` event를 받은 뒤 emulator 크기를 갱신한다. +- 모든 pane은 background에서 계속 실행된다. 키보드·paste·prompt 기록·scroll은 active pane만 대상으로 하고, pane content 바깥의 mouse event는 해당 영역의 명령으로만 처리한다. -- **Visible window**: `TerminalState.visible_start`/`active`가 `[visible_start, visible_start + max_visible)` 인덱스 범위를 정의한다. `max_visible()`은 `TerminalFullscreen` 상태가 결정한다: `Off` → `max_visible_normal`(4), `Grid` → `max_visible_fullscreen`(8), `Zoom` → 1. `TerminalState::sync_visible_window`(순수 함수 `runtime::terminal::visible_range`가 뒷받침)가 이 범위를 항상 `active`를 포함하도록 re-clamp하되, 재중심화가 아니라 **최소한만** 민다. `active`나 pane 개수를 바꾸는 모든 것 뒤에 호출해야 한다 — `create_pane_with`, `switch_pane`, `swap_active_with`, `cycle_focus_forward/backward`, pane close/exit clamp, 세션 복원이 모두 그렇게 한다. **`active`를 바꾸는 새 지점을 짝 없이 추가하는 것은 버그다.** -- **Pane reorder (swap)**: `TerminalState::swap_active_with(idx)`가 정렬된 `panes` Vec에서 active pane과 `idx`의 pane을 교환하고 `active = idx`로 두어 포커스가 옮겨간 pane을 따라간다. Vec 순서만 바뀐다 — pane별 상태(파서, 스크롤, 크기, prompt 버퍼, backend PTY)는 전부 안정적인 `PaneId`로 키를 잡으므로 재정렬이 그것들을 건드리지 않는다. pane 순서는 영속되지 않고(PTY는 살아 있는 프로세스라 재시작 시 `startup_commands`로 다시 만들어진다) swap은 세션 한정이며, 저장된 `active_pane` 인덱스는 `active`가 함께 갱신되므로 일관을 유지한다. ` s`가 두 번째 follow-up 상태(`App::awaiting_swap_target`, `prefix_armed`와 상호 배타)를 arm하고, 다음 digit은 focus-jump digit과 **같은** layout-aware 매핑(`resolve_prefix_action`)으로 풀린다. arming은 ` w`와 같은 terminal-focus 스코프를 공유하고(없으면 swap의 첫 피연산자인 active pane이 구별되지 않게 그려진다) 추가로 pane이 둘 이상이어야 한다. 아니면 키는 소비만 되고 armed 힌트 행도 `s: swap pane`을 숨긴다. -- **Layout-aware jump keys**: leader digit 행은 레이아웃에 따라 매핑이 바뀐다. split view에서 `input::prefix_action`은 `1`=list, `2`=diff, `3`..`9`,`0`=pane `0`..`7`. 터미널이 body를 채우면(`fills_body()`) 상단 뷰어가 숨으므로 `main::resolve_prefix_action`이 `input::prefix_action_fullscreen`으로 갈아끼워 `1`..`8` → pane `0`..`7`로 자연수 번호를 매긴다 (`9`/`0` 제거, 비-jump 키는 그대로). fullscreen에서 list/diff로 돌아가는 jump 키는 없다 — 유일한 출구는 fullscreen을 순환시키는 ` f`다. 탭 바(`render_tab_bar`)가 활성 매핑을 legend에 그대로 반영한다. bare F키 행은 **별개 축**이다: `F1`..`F10`이 프로젝트 탭을 고르고 의도적으로 layout-aware가 아니어서, 한 F키가 모든 뷰에서 한 프로젝트에 닿는다. pane legend가 F키가 아니라 leader 화음을 부르는 이유가 그것이다. -- **Fullscreen cycle**: 터미널 포커스에서 ` f`가 `App::toggle_terminal_fullscreen`으로 `TerminalFullscreen::{Off, Grid, Zoom}`을 `Off → Grid → Zoom → Off`로 순환시킨다. `Grid`와 `Zoom` 모두 상단 뷰어를 숨기고 body 전체를 터미널에 넘긴다(`fills_body()`). `Zoom`은 전용 렌더 경로가 필요 없다 — `max_visible()`을 1로 깎으면 공유 grid 경로가 active pane 하나만 그린다(단일 pane이므로 보더 없음). `Grid`가 pane 하나만 보일 상황에서는 둘이 구별되지 않으므로 사이클이 `Zoom`을 건너뛴다. 그 판정의 단일 출처는 `TerminalState::zoom_distinct_from_grid` (`max_visible_fullscreen.min(panes.len()) > 1`)이고 토글·pane close 정규화·힌트 텍스트가 공유한다. body를 채우는 상태로 들어가면 포커스가 터미널로 가고 경쟁하는 diff/list fullscreen이 해제된다. 마지막 pane을 닫으면 `Off`로 리셋. 영속화는 저장 시 `Zoom`을 `Grid`로 접는다(세션은 bool 하나). -- **Grid layout**: `ui::terminal_tab::split_pane_areas`가 1 pane은 전체 폭, 2는 좌우(좁으면 상하), 3은 2칼럼 행 + 전체 폭 나머지, 4는 2x2, 5–6은 3칼럼, 7은 4행+3행으로 배치한다. 단일 pane은 **보더 없는 전용 코드 경로**를 탄다 — 터미널 출력을 복사할 때(마우스 캡처 중 bypass modifier+드래그, 또는 `[mouse]` 끄고 맨 드래그) 잘못 딸려오는 `│`가 절대 없어야 하고, 이것이 압도적으로 흔한 경우라 회귀시키면 안 된다. -- **Sizing invariant**: `ui::terminal_tab::visible_pane_cells`가 pane Rect의 단일 출처다. `render`가 매 프레임 여기서 그리고, `ui::terminal_content_areas` → `main_loop`의 `resize_visible_panes`도 같은 함수를 읽으므로 pane의 backend PTY + 에뮬레이터 크기가 그려진 셀과 정확히 일치한다. **새 호출 지점에서 pane 크기를 독립적으로 계산하지 말고 이 함수를 통과시킬 것.** 원격 backend에서는 요청 직후 에뮬레이터를 낙관적으로 바꾸지 않고 세션의 `Resized` 확인을 따라간다. 원하는 크기와 확인된 크기가 다르면 재요청하므로 빠른 연속 resize의 마지막 셀 크기로 수렴한다. -- **Input/scroll scope는 그대로**: 키보드 입력, paste, prompt 로깅, 터미널 스크롤 (`TerminalState::active_pane_rows`가 페이지 크기)은 여러 pane이 그려져도 active pane만 겨냥한다. -- **Accent는 "active pane"이 아니라 진짜 포커스를 뜻한다**: accent 색은 앱 전역에서 "이 영역이 지금 키보드 포커스를 갖는다"에만 예약돼 있다(`focused_border_style`, `FileList`/`DiffViewer`가 동일하게 사용). active pane의 셀 보더/탭은 `Focus::Terminal`이 함께 참일 때만 accent를 받고, 아니면 비활성 pane과 픽셀 단위로 동일하게 렌더된다(plain `Color::DarkGray`/`Color::Gray`, bold 없음, 밝은 대체색 없음). +## Terminal emulation -## Terminal Emulation Layer +`runtime::emulator::PaneEmulator`가 pane마다 alacritty_terminal `Term`과 ANSI `Processor`를 감싼다. UI는 `ScreenView`/`CellView`만 보고, VT 구현 타입은 모듈 밖으로 새지 않는다. emulator는 최소 1행 × 2열로 clamp한다. -`runtime::emulator::PaneEmulator`가 pane당 하나씩 alacritty_terminal의 `Term` + ANSI `Processor`를 감싸고, 렌더러는 `ScreenView`/`CellView`로만 화면을 조회한다. alacritty 타입은 이 모듈 밖으로 노출되지 않으므로 에뮬레이터 교체·업그레이드의 영향 범위가 이 파일 하나로 국소화된다 — 그리드를 ANSI 바이트로 되돌리는 `screen_snapshot`(`snapshot.rs`)이 이 모듈 안에 있는 이유도 그것이다. 그 스냅샷이 무엇에 쓰이는지는 [session.md](session.md#스크롤백과-재접속). +- PTY byte는 client emulator에 적용한다. emulator가 OSC 0/2 title, DSR/DA query reply, terminal modes를 수집하고, title은 pane metadata로 세션에 전달한다. +- hub는 재접속을 위해 mode와 screen snapshot을 별도로 보관한다. alternate screen은 현재 screen을, normal screen은 ring history와 snapshot 이후 tail을 조합해 replay한다. reconnect replay는 `screen` 뒤에 `since` byte를 붙여 snapshot 이후 broadcast를 잃지 않는다. +- replay frame은 1 MiB 이하로 분할되고 daemon frame은 4 MiB를 넘지 않는다. terminal stream은 byte를 생략하거나 conflation하지 않으며, frame/queue 상한을 넘긴 연결은 명시적으로 종료한다. -원래는 vt100 크레이트를 썼으나 alacritty_terminal 0.26으로 교체했다. 근거: vt100은 (1) 스크롤백 underflow panic, (2) 스크롤 offset 초과 panic, (3) wide char(한글 등)가 마지막 컬럼에 걸린 채 화면이 축소되면 이후 ED 처리에서 index out of bounds panic(upstream issue #28, 미수정)으로 세 차례 크래시를 냈고 업스트림 유지보수가 정체 상태다. alacritty_terminal은 Alacritty/Zed에서 실전 검증된 활발한 프로젝트로 리사이즈 시 reflow까지 지원한다. 대안으로 검토한 avt(asciinema)는 바이트 입력·OSC 타이틀 통지가 없고, tui-term/shpool_vt100은 내부가 vt100이라 같은 버그를 공유해 제외했다. 단, alacritty의 최소 그리드는 1행 x 2열(`MIN_COLUMNS`)이라 `PaneEmulator`가 요청 크기를 이 최소값으로 클램프한다 — 1열 그리드는 wide char reflow가 무한 루프에 빠진다. +## Scroll routing -- **OSC title capture**: `Term`이 OSC 0/2 타이틀을 `Event::Title`로 통지하면 `PaneEmulator::process`가 수집해 반환하고, `TerminalState::poll`이 `PaneInfo.title`에 반영해 탭 바에서 노출한다. claude/vim/ssh처럼 자체 타이틀을 갱신하는 프로그램은 자동으로 적절한 라벨이 붙고, 타이틀을 보내지 않는 셸은 기본 라벨을 유지한다. -- **프로젝트 attention은 클라이언트 로컬이다**: 숨은 프로젝트의 pane이 BEL을 울리거나, OSC 제목이 최소 세 번·600ms 이상 연속으로 바뀐 뒤 800ms 동안 안정되거나, pane 프로세스가 종료되면 `TerminalState.unread_attention`을 세운다. 제목 조건은 Codex 같은 animated title을 provider 이름이나 spinner 글리프를 하드코딩하지 않고 관측하는 좁은 heuristic이다. 단순 출력 idle은 완료의 증거가 아니므로 쓰지 않는다. 활성 프로젝트는 매 poll 뒤 attention과 진행 중 title 관측을 지운다 — 이미 화면에 보인 활동이 사용자가 다른 탭으로 간 뒤 새 알림으로 되살아나면 안 된다. daemon/session에 저장하지 않는 이유는 attach한 TUI와 브라우저가 서로의 읽음 상태를 지우면 안 되기 때문이다. -- **Terminal query replies**: DSR/DA처럼 내부 프로그램이 터미널에 묻는 쿼리에 대해 에뮬레이터가 생성한 응답(`Event::PtyWrite`)을 `TerminalState::poll`이 해당 pane의 PTY로 되돌려준다. vt100 시절에는 응답이 불가능해 쿼리가 무시됐다. +스크롤은 프로그램이 요청한 mode를 보고 sink를 고른다. -## Scroll Routing +| `ScrollSink` | 조건 | 전달 | +| --- | --- | --- | +| `MouseWheel` | mouse mode + SGR mouse | SGR(1006) wheel report | +| `ArrowKeys` | alternate screen + alternate scroll | xterm 방향키 | +| `Scrollback` | 그 외 | emulator scrollback만 변경 | -터미널 스크롤 키(`Shift+↑/↓`, `Shift+PgUp/PgDn`)는 항상 에뮬레이터 스크롤백을 움직이는 게 아니라 **pane 안의 프로그램이 기대하는 입력으로 변환**되어 전달된다. 자기 뷰포트를 직접 소유하는 프로그램은 트랜스크립트를 에뮬레이터 그리드가 아니라 자기 메모리에 두므로 그리드를 스크롤해도 드러날 내용이 없다. 특히 alacritty는 alternate screen 그리드를 스크롤백 0으로 만든다 (`Grid::new(lines, cols, 0)`). +`Scrollback`이 기본값이며, mode를 켜지 않은 shell에는 합성 byte를 보내지 않는다. 합성 scroll report는 human input 경로와 prompt log를 우회한다. -어디로 보낼지는 프로그램이 스스로 켠 모드가 알려준다. `PaneEmulator::scroll_sink()`가 판정하고 `TerminalState::scroll_active`가 실행한다. +## Mouse routing -| `ScrollSink` | 조건 | 전달할 입력 | 해당 프로그램 | -|---|---|---|---| -| `MouseWheel` | `MOUSE_MODE` + `SGR_MOUSE` | SGR(1006) 휠 리포트 | Claude Code, `less --mouse` | -| `ArrowKeys` | `ALT_SCREEN` + `ALTERNATE_SCROLL` | 방향키 (xterm alternateScroll) | `less`, `man` | -| `Scrollback` | 그 외 (기본값) | 없음 — 에뮬레이터 뷰를 스크롤 | bash, zsh | +`[mouse] enabled`가 켜져 있으면 crossterm이 화면을 캡처한다. `pane_at`은 렌더와 같은 `terminal_content_areas`를 사용한다. pane press는 focus와 active pane을 바꾸고, 프로그램이 mouse button mode + SGR encoding을 요청한 경우에만 pane-local SGR button report를 보낸다. release는 포인터 현재 위치가 아니라 press를 받은 pane에 짝지으며, pane이 닫히거나 숨겨졌으면 버린다. -우선순위는 xterm과 같다. 휠을 요청한 프로그램은 alternate screen에서도 휠을 받는다. `MOUSE_MODE`만 있고 `SGR_MOUSE`가 없으면 legacy X10 인코딩을 기대하는 것인데, 223열을 넘기지 못하는 그 인코딩을 위해 두 번째 인코더를 두는 대신 `Scrollback`으로 떨어뜨린다. - -`Scrollback`이 기본값이어야 하는 이유는 안전 문제다. bash/zsh는 바인딩되지 않은 이스케이프 시퀀스를 받으면 BEL을 울리고 `;2A` 같은 잔여 문자를 프롬프트에 그대로 삽입한다. 따라서 스크롤을 청구하지 않은 pane에는 **한 바이트도 보내지 않는다**. - -합성한 입력은 `send_input`이 아니라 `write_pty`로 나간다. 사용자가 누른 키가 아니므로 스크롤 위치를 초기화하거나 prompt log에 남으면 안 된다 — 에뮬레이터의 쿼리 응답이 `send_input`을 우회하는 것과 같은 이유다. - -## Mouse Routing - -`[mouse] enabled`(기본 on)일 때 crossterm `EnableMouseCapture`로 마우스를 캡처한다. 캡처는 화면 전체 단위라 pane별로 쪼갤 수 없으므로, 바깥 터미널의 네이티브 텍스트 선택은 modifier+드래그 오버라이드로 우회한다(bypass modifier는 터미널마다 다르다 — xterm 계열 Shift, iTerm2 Option, macOS Terminal.app Fn/Option). 끄면 마우스는 바깥 터미널 소유로 돌아간다. - -캡처된 이벤트는 `main::handle_mouse`가 `ui::pane_at`으로 hit-test한다. `pane_at`은 렌더링과 동일한 `terminal_content_areas` 기하를 재사용하므로 화면과 판정이 어긋날 수 없다. pane content 셀 밖(상단 패널, 보더, 탭 바)에 떨어진 이벤트는 버린다. - -- **상단 패널 클릭**: pane content 밖의 press는 `ui::upper_panel_at`(draw와 동일한 split 기하)으로 다시 판정해, 리스트/diff 영역이면 focus만 옮긴다(F1/F2와 동일). fullscreen에서는 판정하지 않는다 — body를 채운 패널이 이미 focus를 갖는다. -- **클릭**: press가 클릭된 pane을 활성화하고 focus를 터미널로 옮긴다 — jump key와 동일. press/release는 `TerminalState::click_pane`이 pane-local 1-based 좌표의 SGR(1006) 버튼 리포트로 변환하되, `PaneEmulator::wants_mouse_buttons`(`MOUSE_MODE`+`SGR_MOUSE`)를 켠 프로그램에만 보낸다. 스크롤과 같은 침묵 규칙이며, 클릭은 스크롤백 폴백이 없으므로 미청구 클릭은 조용히 버려진다. -- **release 짝짓기**: release는 포인터 아래 pane이 아니라 **press를 받은 pane**으로 간다 (`App::pending_mouse_press`, single slot). 드래그 리포트를 포워딩하지 않으므로 프로그램은 포인터 이탈을 스스로 알 수 없다 — press를 본 프로그램은 release도 봐야 하고, 포인터가 우연히 머문 pane이 press 없는 release를 받아서는 안 된다. release 좌표는 press pane의 현재 rect로 클램프하고, 그 pane이 닫혔거나 숨겨졌으면 release를 버린다. -- **휠**: 활성 pane이 아니라 **포인터 아래 pane**을 `scroll_pane`으로 스크롤한다. sink 판정은 위 표와 동일하되 `MouseWheel` sink의 리포트 좌표는 실제 포인터 셀을 그대로 전달한다(키보드 스크롤만 pane 중앙 폴백 — 포인터가 없으므로). 비활성 pane의 `Scrollback` sink에는 per-frame `sync_scroll`(활성 pane 전용)이 닿지 않으므로 `scroll_pane`이 오프셋을 즉시 직접 적용한다. -- **탭 바 클릭**: `ui::tab_click_at` → `terminal_tab::tab_target_at`. 탭/`+N` 마커 세그먼트와 클릭 타겟은 렌더러와 공유하는 `tab_segments` 빌더가 단일 소스다. 탭 클릭은 jump key와 동일하게 `switch_pane`을 타고, `+N` hidden 마커는 그쪽 방향의 가장 가까운 hidden pane으로 점프해 `sync_visible_window`가 창을 한 칸만 슬라이드한다. -- **힌트 바 클릭**: 최하단 행의 press는 `ui::hint_click_at`이 렌더러와 동일한 힌트 텍스트 (`normal_hint_literal`/`prefix_armed_hint_text` 공유)를 display width로 세그먼트화해 판정한다. 이산 명령(` t/w/f/l/b/o`, armed row의 follow-up, 포커스된 패널이 프리픽스 없이 받는 `v`/`s`/`/`/`n`/`shift+n`)만 클릭 가능하고, 연속 내비게이션·digit legend·`esc`는 비클릭이다. 대상은 `segment_click`의 명시적 키 목록이다 — 힌트 텍스트만으로는 명령과 내비게이션을 구분할 수 없으므로, `hint_text`에 명령을 추가해도 이 목록에 넣기 전까지는 조용히 비클릭으로 남는다. bare `: leader` 라벨도 클릭 가능하며 leader chord keypress를 합성해 프리픽스를 arm한다 — "leader 클릭 → 명령 클릭"의 마우스-only 플로우가 이어진다. **`q: detach`는 오클릭 한 번으로 TUI가 떨어져 나가지 않도록 의도적으로 제외**했다. 디스패치는 라벨이 가리키는 키 입력을 그대로 합성해 `handle_key`로 보낸다 — 클릭과 실제 키가 모든 가드와 코드 경로를 공유하므로 클릭이 키와 다른 동작을 할 수 없다. `r: redraw`의 `KeyOutcome` 전파를 위해 `handle_mouse`도 `KeyOutcome`을 반환한다. 클릭 가능한 세그먼트는 `hint_spans`가 `key: description` 라벨 전체를 REVERSED로 렌더링해 어포던스를 표시하고, 판정을 `segment_click`과 공유하므로 반전 범위와 hit-test가 어긋날 수 없다. `[mouse] enabled = false`면 반전도 꺼진다. -- **swap 모드 클릭**: ` s` 대기 중의 좌클릭은 digit follow-up과 동일하게 **swap 대상 지명**으로 해석한다 — pane 또는 그 탭을 클릭하면 활성 pane과 교환하고, pane을 지명하지 않는 press는 consume+disarm. 이 분기가 없으면 클릭이 swap 상태를 방치한 채 활성 pane만 바꿔 다음 digit이 엉뚱한 pane을 교환한다. -- **드래그/모션**: 포워딩하지 않는다. 내부 프로그램의 자체 텍스트 선택은 지원 범위 밖이고, 텍스트 선택은 바깥 터미널의 bypass modifier+드래그가 담당한다. - -합성 버튼 리포트도 스크롤과 같은 이유로 `send_input`이 아니라 `write_pty`로 나간다. +wheel은 포인터 아래 pane을 대상으로 하며 sink 규칙은 keyboard scroll과 같다. tab bar와 hint bar의 클릭 대상은 렌더러가 만든 segment에서 파생하고, ` s` 대기 중 pane 클릭은 swap target으로 해석한다. motion/drag는 PTY로 전달하지 않는다. 외부 터미널의 text selection은 capture bypass modifier를 사용한다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/ui.md b/docs/architecture/ui.md index 8a52134e..04bd6789 100644 --- a/docs/architecture/ui.md +++ b/docs/architecture/ui.md @@ -1,94 +1,35 @@ # UI & Input -키가 어디로 가는지(leader 모델), 한 프로세스가 저장소 N개를 탭으로 여는 경계(`Workspace`/`App`), 그리고 하단 크롬 두 행 중 위쪽인 notice row를 다룬다. 세 주제는 한 제약을 공유한다 — **1순위 사용자는 pane에서 LLM CLI를 굴리는 cockpit 사용자**이므로, 앱이 가로채는 키와 화면에 생겼다 사라지는 행을 최소로 유지한다. +이 문서는 TUI의 프로젝트 경계, 키 입력 라우팅, redraw와 하단 chrome 계약을 다룬다. 터미널 안에서 동작하는 프로그램을 우선하므로 앱이 가로채는 입력과 레이아웃 변동을 최소화한다. -## Keyboard Routing +## Keyboard routing -라우팅은 leader(prefix) 모델을 따른다. `Ctrl+W`/`Ctrl+L` 같은 프롬프트 편집 Ctrl 키가 nightcrow에 가로채이지 않고 PTY로 통과해야 하므로, 앱 전역 명령은 leader 뒤에 한 키를 눌러야만 실행된다. +기본 leader는 `Ctrl+F`이며 `[input] leader`에서 `ctrl+`로 바꿀 수 있다. leader를 누르면 다음 key 하나를 앱 명령으로 해석하고, 매핑·미매핑·`Esc`/`Ctrl+C` 어느 경로든 prefix 상태를 끝낸다. timeout은 없다. ` `는 terminal focus에서 literal leader를 PTY로 보낸다. -- **Leader (prefix)**: 기본값 `Ctrl+F`, `[input] leader`로 변경 가능(`config.rs::parse_leader`가 `ctrl+`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면 `App.interaction.prefix_armed`가 켜지고 다음 키 한 개가 앱 명령(`input::prefix_action`)으로 해석된다. **타임아웃은 없다** — 해제 경로는 셋뿐이다: 매핑된 키 → Action 실행 후 해제, 미매핑 키 → 소비 후 해제, `Esc`/`Ctrl+C` → 취소. ` `는 terminal focus에서 leader를 `encode_key`로 리터럴 PTY 전송한다. -- **prefix 매핑**: `t`=NewPane, `w`=ClosePane(terminal focus 한정 — unfocus 시 active pane이 다른 pane과 동일하게 그려져 닫힐 대상이 보이지 않으므로, 키는 소비하되 no-op이고 힌트 바에도 노출하지 않는다), `s`=pane swap 대기 arm(같은 terminal-focus 스코프 + pane 2개 이상 — [terminal.md](terminal.md#split-view-terminal-panel) 참고), `c`=CancelRecovery(대기 중인 것이 있을 때만 힌트에 노출), `l`=ToggleLogView, `b`=ToggleTreeView, `f`=ToggleFullscreen, `o`=OpenProject(저장소를 새 프로젝트 탭으로 — 제자리 교체 명령은 없다), `x`=CloseProject, `p`=CycleTheme, `r`=Redraw, `q`=Quit. 숫자는 지금 body가 보여주는 것을 지시한다: `1`=FocusList, `2`=FocusDiff, `3`–`9`,`0`=pane 0–7 포커스 이동(`0`은 digit이 9까지뿐이라 8번째 pane). pane 포커스 이동은 탭 전환이 아니라 어떤 pane이 active인지만 바꾼다 — grid는 이동 전후로 계속 여러 pane을 동시에 그린다. -- **No-prefix 예약키**: `F1`–`F10`(프로젝트 탭 1–10 — layout에 따라 바뀌지 않는 유일한 점프 축), `Shift+←/→`(focus cycle — terminal focus에서는 active pane을 앞/뒤로 이동), `Shift+↑/↓`·`Shift+PgUp/PgDn`(터미널 스크롤, active pane 기준 — [terminal.md](terminal.md#scroll-routing) 참고)는 leader 없이 항상 앱이 먼저 처리한다. modifier 또는 F-key라서 프롬프트 텍스트와 혼동되지 않는다. -- **Upper panel focused**: 나머지는 로컬 네비게이션(`j`/`k`, `/`, `v`, `n`/`N`, `Enter`, `Esc`, 화살표, `PgUp`/`PgDn`)이다. `j`/`k`는 upper-pane handler 내부에서 vim navigation으로 변환되며, `map_key`는 plain character로 통과시켜 terminal focus에서 PTY로 그대로 전달되게 한다. -- **Lower panel focused (terminal)**: leader/예약키가 아닌 모든 키는 active backend의 stdin으로 직접 통과한다(`encode_key`가 화살표/F-key/제어문자를 VT100 시퀀스로 인코딩). 단독 `Ctrl+T/W/L/O/P/Q` 등은 control byte로 PTY에 간다(리더 `Ctrl+F`만 arm하고 통과하지 않는다). bare F키는 앱이 가로채므로 pane 안 프로그램(htop, mc 등)의 F키 메뉴는 동작하지 않는다 — 수정자를 붙인 `Ctrl+F1`, `Shift+F5` 등은 통과한다. -- **Paste**: `Event::Paste`는 `dispatch_paste`로 가고, terminal focus면 ESC·NUL을 걷어낸 뒤 pane 프로그램이 DECSET 2004를 켰을 때만 `ESC[200~ … ESC[201~`으로 감싼다(`input::paste`). **Windows에는 paste input record가 없어** 문자 단위 key burst로 들어오므로 5 ms 간극까지 이어 훑어(최대 8192건 / 250 ms) synthetic `Event::Paste`로 바꾼다(`input::burst`). 콘솔이 붙여넣기를 점진적으로 넣기 때문에 zero-wait poll은 단어 중간에서 끊긴다. 판정은 좁다 — 수정자 없는 문자/Enter press만이고 **Enter 뒤에 문자가 오거나** 문자 16개 초과일 때만 paste. Enter는 줄을 넘기므로 그 뒤에 남은 문자가 곧 Enter가 제출하지 않은 내용이라는 증거다. 줄 끝의 Enter는 뒤가 비어 있으니 타이핑으로 남고, 그래서 느린 frame에 키가 밀려 한 burst로 들어와도 제출이 붙여넣기로 바뀌지 않는다. 타이핑을 삼키는 오탐이 더 비싸기 때문이고, 어긋나면 순서 그대로 평소 dispatch로 되돌린다. -- overlay(repo input/search)가 활성이면 leader dispatch가 금지되고 overlay가 키를 소유한다. armed 중 overlay가 열리는 경로면 prefix를 취소한다. repo 다이얼로그는 `Workspace` 소유라 `main::dispatch_key`가 per-project 핸들러보다 먼저 처리한다 — 프로젝트가 없을 때도 열려야 하기 때문. -- **프로젝트가 없을 때**: `main::handle_empty_key`가 leader arming과 `o`/`q`만 해석하고 나머지는 버린다. ` `는 여기서 액션 테이블로 넘어가지 않는다 — 기본 leader가 `ctrl+f`라 follow-up이 `f`에 매칭돼 fullscreen이 토글될 수 있기 때문. -- 좌/우 패널 타이틀에는 현재 포커스 단축키(` 1` / ` 2`)가 노출된다. `ui::jump_legend`가 leader label과 digit을 **공백으로** 이어 붙인다 — `^F1`로 붙여 쓰면 Ctrl+F1로 읽히고, 그 조합은 앱이 가로채지 않고 PTY로 통과시키는 별개 키라 오해를 만든다. +- leader 명령은 `t`(new pane), `w`(close pane), `s`(swap target), `z`(claim PTY size), `l`(log), `b`(tree), `f`(fullscreen), `o`(open project), `x`(close project), `p`(theme), `u`(reload config), `r`(redraw), `q`(quit), `c`(cancel recovery)다. `c`는 대기 중 recovery가 있을 때만 힌트에 보인다. `w`와 `s`는 terminal focus와 pane 수 조건을 만족할 때만 실행한다. +- split layout에서 leader digit `1`/`2`는 file list/diff focus, `3`–`9`와 `0`은 pane `0`–`7`이다. terminal fullscreen에서는 `1`–`8`을 pane `0`–`7`에 자연스럽게 매핑하고 `9`/`0`은 버린다. bare `F1`–`F10`은 layout과 무관하게 project tab `0`–`9`를 선택한다. +- prefix 없는 예약키는 bare F-key와 shift-only arrow/PageUp/PageDown이다. 그 밖의 일반 key와 단독 Ctrl은 active backend의 stdin으로 전달한다. 따라서 pane 안의 `Ctrl+W`, `Ctrl+L` 같은 편집키를 앱이 훔치지 않는다. bare F-key를 앱이 사용하므로 pane 프로그램의 F-key 메뉴는 수정자를 붙여야 한다. +- paste는 terminal의 bracketed-paste mode일 때만 escape로 감싸며 ESC/NUL은 제거한다. Windows console에서 문자 burst로 들어오는 paste는 제한된 간격·길이 안에서만 합성 paste로 묶고, 판정이 불확실하면 원래 key 순서로 되돌린다. overlay가 열렸거나 project가 없으면 overlay/empty-state가 먼저 입력을 소유한다. +- scroll·mouse report·query reply 같은 합성 입력은 프로그램이 요청한 mode일 때만 PTY로 보낸다. 앱 명령이 아닌 terminal key를 notice 해제 입력으로 세지 않는다. -## Project Boundary (`Workspace` / `App`) +## Project boundary -한 프로세스가 저장소 N개(최대 `MAX_PROJECTS` = 10, F1~F10 키 공간과 일치)를 탭으로 연다. +`Workspace`는 최대 `MAX_PROJECTS = 10`개의 `App`을 Vec와 active index로 관리한다. `App`은 한 저장소의 GitViewManager, pane 집합, 포커스·fullscreen·notice를 소유한다. active가 없을 수 있으므로 마지막 탭을 닫은 뒤에도 repo dialog와 quit만 동작한다. 같은 canonical worktree는 두 번 열지 않고 기존 탭으로 focus한다. 숨은 project의 terminal attention은 해당 TUI client에서만 읽음 처리한다. -- `App` = 저장소 하나의 상태 전부. 터미널 pane도 `App`에 있으므로 프로젝트마다 자기 PTY 집합과 cwd를 갖는다. -- `Workspace` = `Vec` + 활성 인덱스. 탭 전환은 프로젝트 작업 상태를 건드리지 않으며, 클라이언트 로컬 attention만 읽음 처리한다. 목록은 **비어 있을 수 있다** — 인자 없는 실행이 그 상태이고, 마지막 탭을 닫아도 그리로 돌아온다. 그래서 `active()`가 `Option`이다. -- 숨은 프로젝트의 terminal attention은 F-key와 탭 이름 사이의 기존 공백을 `•` 한 셀로 바꿔 집계한다. 밝음/어두움만 1초마다 바꿔 점멸하므로 표시·해제 또는 점멸 중에도 텍스트 폭과 mouse hit box는 움직이지 않는다. 프로젝트가 활성화되어 한 frame의 terminal event를 소비하면 그 클라이언트에서만 읽음 처리한다. +repo open dialog는 Workspace 레벨에서 먼저 처리되므로 project가 0개여도 열 수 있다. 경로 입력은 셸을 실행하지 않고 `read_dir` 한 단계만으로 directory 후보를 완성한다. `~`와 상대 표기는 읽을 때만 확장하며 사용자가 입력한 텍스트는 그대로 보존한다. directory browser는 평면 row list로 확장/접기를 관리하고, 경로를 확정하는 것은 field의 `Enter` 한 곳이다. -저장소를 "교체"하는 경로는 없다. 탭을 닫으면 `App`이 drop되면서 `SnapshotChannel`이 worker를 join하고 `TerminalState`가 자식 프로세스를 정리하므로, 손으로 유지하는 초기화 목록이 존재하지 않는다. 제자리 교체는 pane을 살려두는 탓에 탭 라벨과 셸의 작업 디렉토리가 어긋나기도 했다. +TUI workspace state는 `~/.nightcrow/workspace.json`에 저장한다. 열린 project, active project와 project별 view를 기록하지만 저장소 내부에는 기록하지 않는다. 복원된 status 선택처럼 snapshot이 필요한 값만 pending으로 두며, background project의 queue는 매 tick 비우되 snapshot 적용은 active project에서 한다. worker join과 snapshot watch의 세부 규칙은 [session.md](session.md)를 따른다. -**프로세스 레벨 상태** — 저장소 열기 다이얼로그(`repo_input`)는 `Workspace`에 있다. 프로젝트가 없을 때도 동작해야 하는데, 그때가 바로 이 다이얼로그가 유일한 행동이기 때문이다. 반면 `handle_key`는 여전히 `&mut App` 하나만 받는다 — `dispatch_key`가 워크스페이스 레벨 경우를 먼저 해소하므로, 프로젝트별 입력 경로 전체가 프로젝트 하나만 아는 채로 유지된다. 워크스페이스 수준 의도는 `KeyOutcome::Project(ProjectRequest)`로 반환하고 `main_loop`이 실행한다. +## Layout and redraw -### 경로 완성 (`workspace/path_complete.rs`) +`ui::chrome::chrome_rows`가 project tabs, body, notice, hint 네 행을 항상 만든다. body의 upper/lower split은 TUI layout config에서 계산하고, terminal pane rect는 [terminal.md](terminal.md)의 단일 기하 출처를 사용한다. notice나 dialog 때문에 행을 추가·삭제하지 않는다. -다이얼로그의 `Tab`이 여기로 간다. 셸을 PTY로 띄우지 않는 이유와 대안 비교는 [decisions.md](../decisions.md)에 있다 — 요약하면 Windows에 readline 대응 프리미티브가 없어서 네이티브 완성기가 어차피 필요하다. 규칙은 무상태 하나다: **확장할 게 있으면 확장하고, 없으면 후보를 보여준다.** 단 fragment가 비어 있으면(구분자로 끝나는 상태) 확장과 동시에 목록도 낸다 — 그때의 `Tab`은 "여기 뭐가 있냐"는 질문이라 조용한 확장은 답이 아니다. Tab 한 번에 `read_dir` 한 단계만 읽고 디렉터리만 후보로 삼는다. +입력·PTY output·snapshot/load 결과·tree watch·resize·recovery·title 변화는 dirty frame을 요청한다. event loop는 16 ms마다 queue를 poll하지만 변경 없는 tick에는 `Terminal::draw`를 호출하지 않는다. ` r`만 front buffer를 비우는 명시적 full repaint다. status의 hot-file fade와 attention/search caret 경계도 timer event로 dirty를 만든다. -- **사용자가 입력한 텍스트는 다시 쓰지 않는다.** `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 완성된 컴포넌트만 이어붙인다 — `~/x`를 `/Users/me/x`로 바꿔 써넣으면 사용자가 타이핑한 적 없는 경로가 화면에 남는다. -- `git::tree::read_children`(`ViewMode::Tree`용)을 쓰지 **않는다**. 그쪽은 `git2::Repository`가 필수이고 repo-relative 경로만 받으며 워크트리 밖 경로와 심볼릭 링크를 거부하는데, 피커는 어떤 repo에도 속하지 않는 경로를 돌아다녀야 하고 프로젝트가 0개일 때도 떠야 한다. 심볼릭 링크 정책도 반대다 — 트리는 따라가지 않지만(순환 방지) 피커는 따라간다(링크된 체크아웃이 실제 repo다). -- 후보는 hint 행에 표시한다(`repo_dialog::repo_dialog_hint_line`). 우선순위는 notice > 후보 > legend — notice 행이 자유로울 때 적용하는 것과 같은 순서다. 플로팅 팝업을 쓰지 않은 이유는 `src/ui/`에 오버레이 인프라가 없고(모든 surface가 레이아웃 행을 차지한다) 마우스 캡처가 기본 on이라 `hit_test.rs`에 새 히트 영역이 필요해지기 때문이다. +## Notice row -### 디렉터리 브라우저 (`workspace/path_tree.rs` + `ui/path_tree.rs`) +notice row는 정상일 때 repo display path, branch, tracking(`↑N ↓M`)과 recovery marker를 표시하고, 값이 없으면 해당 chip을 생략한다. 폭이 부족하면 path와 branch만 줄이며 tracking/recovery 폭은 보존한다. `App::notice`가 있으면 row를 덮되 body 크기는 바꾸지 않는다. repo dialog가 열리면 입력 line이 notice row를 차지하고 notice와 후보/legend는 hint row에서 우선순위대로 보인다. -경로를 아는 경우(형제 체크아웃 — prefill이 노리는 케이스)는 타이핑이 빠르고 모르는 경우는 브라우저가 낫다. 둘은 경쟁이 아니라 계층이다. - -- **진입은 `↓`**(또는 `↑`). printable 문자는 전부 합법 경로 문자라 쓸 수 없고, 필드의 수평 키(`→`/`End`=prefill 수락)는 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 있다. `Ctrl+T`는 접었다: `T` 니모닉이 ` t`와 겹치고, 다이얼로그의 다른 키가 전부 bare인데 Ctrl 화음만 튄다. -- **후보 목록이 떠 있을 때의 두 번째 `Tab`도 브라우저로 승격한다.** 그 상태의 Tab은 같은 목록을 다시 그리는 죽은 키였고, 평면 목록이 실패한 지점이 정확히 거기다. -- **`Enter`는 확정이 아니라 필드로 되돌리며 경로를 채운다.** repo를 실제로 여는 지점은 필드의 `Enter` 한 곳뿐이다. 그래서 브라우저에서는 확장이 `→` 전용이다(트리 뷰도 확장은 `→`/`←` 전용이며 `Enter`는 파일 열기다). -- **평면 row 리스트**로 들고 있다. 확장은 자식을 부모 뒤에 splice, 접기는 아래 깊은 row를 drain — 선택이 화면 인덱스 그대로여서 프레임마다 flatten이 없다. -- **사용자 표기를 보존한다**(완성기와 같은 이유). `root_text`(타이핑한 그대로)와 canonical `PathBuf`를 따로 들고, 고른 경로는 `root_text` 기준으로 조립한다. `←`가 depth 0에서 루트를 한 단계 올릴 때만 예외 — `~`나 Windows 드라이브의 부모는 사용자 표기로 표현할 수 없으므로 절대 경로로 대체하되, 텍스트 수술을 믿지 않고 `canonicalize` 결과를 실제 부모와 대조해 검증한다. -- **body 전체를 쓴다**(위의 팝업 부재와 같은 이유). 다이얼로그가 이미 모든 키를 소유하므로 view mode·fullscreen 분기보다 앞에서 body를 가로챈다. 마우스 클릭 선택은 범위 밖. 세션 저장도 하지 않는다: 필드가 활성 프로젝트 경로로 prefill되므로 "지난 위치"가 새 영속 상태 없이 따라온다. -- 브라우저를 열면 `prefilled`가 해제된다. 브라우저는 버퍼에 전체 경로를 쓰므로, 플래그가 살아 있으면 복귀 후 첫 타이핑이 방금 고른 경로를 지운다. - -입력 필드는 notice 행의 repo 헤더 자리에 그려진다(`repo_dialog::repo_input_line`) — 헤더는 떠나는 repo를, 입력은 여는 repo를 말하는데 지금 결정 중인 것은 하나뿐이고, 행을 통째로 가지면 경로가 legend와 폭을 다툴 일이 없다. 다이얼로그의 키는 그 아래 hint 행이 알린다 (`repo_dialog::repo_dialog_hint_line`) — 다이얼로그가 평소 legend를 대체하므로 키를 알릴 다른 자리가 없고, 거부 notice와 Tab 후보가 뜨면 잠시 legend를 덮는다(어느 쪽이든 편집 한 번에 사라지므로 legend가 오래 가려지지 않는다). - -### Polling · 세션 · 자원 - -- **Polling 규칙** — 모든 프로젝트가 매 tick 자기 큐를 비우지만(스냅샷 worker, git-load worker와 PTY reader가 계속 생산하므로), 스냅샷을 *적용*하는 것은 활성 프로젝트뿐이다. 배경 스냅샷은 `pending_snapshot`에 대기하다 탭이 앞으로 나온 첫 tick에 적용된다. git-load 결과는 git I/O 없이 generation을 확인하고 메모리 상태만 교체하므로 숨은 프로젝트도 즉시 비운다. 그래야 탭을 떠난 사이 끝난 이전 선택 결과가 큐에 남아 복귀 frame을 되돌리지 않는다. -- **중복 방지** — 다른 탭이 이미 연 저장소는 두 번 열지 않고 그 탭으로 포커스를 옮긴다. 같은 workdir에 프로젝트 두 개는 스냅샷 worker가 중복으로 돌고 같은 session 파일에 쓴다. git 저장소가 아닌 경로는 canonicalize해서 철자 차이(`/w` vs `/w/`)가 이 검사를 빠져나가지 못하게 한다. -- **세션** — 열린 탭 목록, 활성 탭, 저장소별 뷰 상태가 모두 `~/.nightcrow/workspace.json` 한 파일에 들어간다. 저장소 안에는 아무것도 쓰지 않는다: 어떤 저장소도 "옆에 다른 셋이 열려 있었다"는 사실을 소유하지 않는다. 뷰 상태는 최근 사용한 50개 저장소까지 LRU로 유지한다. `--repo`가 주어지면 탭 목록은 복원하지 않는다 — 명시적 인자가 이긴다. 빈 목록도 기록한다: 탭을 다 닫고 종료하는 것이 다음 실행을 빈 화면으로 시작하는 방법이고, 기록을 건너뛰면 이전 탭이 되살아난다. -- **복원 시점** — 세션은 로드 즉시 적용한다. pane/focus/fullscreen은 어떤 데이터도 필요 없고, Log는 commit log를, Tree는 디렉토리를 직접 읽는다. 유일한 예외가 Status 모드의 파일 선택인데, 변경 파일 목록이 필요해 `pending_selection`에 대기한다. 이 지연은 사용자 조작과 충돌할 수 없다 — 빈 목록에서는 선택할 파일이 없기 때문이다. -- **자원 (측정치, 2026-07-20)** — 저장소 10개(각 파일 30개, 그중 10개 dirty), 프로젝트당 pane 2개, release 빌드: - - | | 1 프로젝트 | 10 프로젝트 | - |---|---|---| - | 스레드 | 7 | 70 | - | RSS | 38MB | 43MB | - | 자식 프로세스 | 1 | 19 | - | 유휴 CPU | — | 20초에 0.47초 (~2.4%) | - - 메모리는 프로젝트당 0.5MB 남짓만 늘어 사실상 문제가 아니고, 유휴 CPU도 낮다. 탭 전환은 인덱스 변경이라 실측 70ms 수준(대부분 렌더링). 주목할 것은 **스레드가 프로젝트당 7개로 선형 증가**한다는 점이다(snapshot worker, git-load worker, commit-log fetch, PTY당 reader/wait 쌍). 70개 자체는 문제가 아니지만 이를 막고 있는 것은 `MAX_PROJECTS`(10)와 pane 상한(8)이다. 상한을 올리자는 논의가 나오면 이 선형성을 근거로 재검토해야 한다. 표의 스레드 수는 git-load worker 추가분을 구조적으로 반영한 값이고, 나머지 측정치는 pane 2개 기준이라 최악(10 × 8)은 재보지 않았다. -- **로그 경로** — 로그 파일은 시작 시 한 번 열리므로 활성 탭을 따라갈 수 없다. 첫 `--repo`를, 그것도 없으면 작업 디렉토리를 고정 기준으로 삼는다. - -## Polling and dirty frames - -The attached TUI still polls its input and runtime queues every 16 ms so PTY output, snapshot results, tree watcher events, and log pages are noticed promptly. Polling is not a frame clock: `application::redraw::RedrawState` requests a draw only for a state-changing event, a terminal-size change, or a visible attention/search-caret phase change. The first frame is always drawn; an unchanged idle tick does no `Terminal::draw` call. Terminal polling reports output, title, resize, pane lifecycle, recovery, delayed synchronized-update, and settled-title activity so a redraw cannot depend only on keyboard input. The status list also schedules the exact Fresh→Warm (5 seconds) and Warm→Cool (`hot_window_secs`) boundaries for the active repository, so its fade remains correct without a 60 FPS frame clock. - -` r` remains an explicit full repaint: it clears ratatui's front buffer and marks the next loop dirty, covering terminal programs that left cells the diff renderer cannot know about. Input events also request a frame before their effects are observed, which keeps prefix/overlay/focus changes visible even when a PTY echo is delayed. Resize events and direct size observations share the same dirty path, so a missed crossterm resize notification cannot leave the new geometry unpainted. - -The release measurement is intentionally opt-in because it starts a real shell: `cargo test --release measure_dirty_redraw -- --ignored --nocapture`. It prints before/after draw counts and `ratatui::Terminal` CPU timings for idle, one-second-heartbeat, and every-tick event streams, followed by p95 echo latency from a real `PtyBackend`, plus the event-to-next-frame p95 bound. The draw-count and poll-bound assertions are deterministic; timings and PTY latency are machine-specific evidence, not CI thresholds. - -## Notice Row - -힌트 바 바로 위 한 행. 평상시에는 `ui::mod::render_repo_header`가 repo 경로(`~/...` 형식으로 home-relative 표기), 현재 브랜치, upstream tracking 상태(`↑N ↓M`)를 노출한다. 브랜치/추적 정보는 snapshot worker가 채워주고, detached HEAD/unborn branch처럼 값이 없으면 해당 칩만 생략한다. 마지막 칩은 plugin이 보고한 pane recovery(state·deadline·attempt·detail)이며 대기 중인 것이 있을 때만 나타난다 — [plugin-host.md](plugin-host.md)의 Recovery Surface 참고. - -**행에 안 들어가면 줄어드는 쪽은 두 이름이다**(`fit_names`). 경로와 브랜치는 `…`로 잘리고, 그 뒤의 `↑N ↓M`과 recovery 칩은 제 폭을 지킨다 — 짧고, 이 행에서만 하는 말이기 때문이다. 브랜치는 남은 자리의 **절반까지만** 가져가 긴 브랜치가 경로 자리를 통째로 먹지 않게 하고, 절반이 0이면 아예 뺀다(`…` 하나는 브랜치 이름이 아니면서 칸은 차지한다). 절반이라는 몫은 web viewer의 footer와 같다(`RepoShell.tsx`) — 같은 저장소가 두 화면에서 같게 읽혀야 한다. - -**알림(`App::notice`)이 올라오면 이 행을 덮는다.** 전용 행을 따로 만들지 않은 이유는 알림이 뜨고 사라질 때마다 body가 한 행씩 줄었다 늘어나면서 **열려 있는 모든 PTY가 리사이즈**되기 때문이다. 이 행의 내용은 매 프레임 `App`에서 다시 계산되는 ambient 정보라 잠시 덮어도 잃는 것이 없다. repo 다이얼로그가 열리면 우선순위가 뒤집힌다: 사용자가 편집 중인 입력 텍스트는 덮으면 안 되므로 입력이 이 행을 차지하고, 알림과 Tab 후보는 그동안 hint 행으로 내려간다 (`repo_dialog::repo_dialog_hint_line`). - -알림은 `Notice { kind: NoticeKind, text }` 타입이고, **만료는 메시지 문자열이 아니라 kind로 판정한다**. 이전에는 `msg.starts_with("git error:")` 같은 접두사 매칭이라 (a) 사람이 읽는 문구에 해제 로직이 묶여 있었고 (b) 매칭 arm이 없는 종류(`Terminal`/`Tree`/`Session`)는 repo를 바꾸기 전까지 영영 사라지지 않았다. 해제 경로는 둘이다: - -- **같은 kind의 성공** — `App::clear_notice(kind)`. 각 서브시스템의 성공 경로에서 호출하며, 그 사이 도착한 다른 종류의 알림은 건드리지 않는다. -- **앱 레벨 키 입력** — `App::dismiss_notice_on_app_input()`. PTY로 그대로 포워딩되는 키는 **제외**한다. 터미널 패널에서는 모든 키가 passthrough라 포함시키면 사용자가 타이핑을 재개하는 순간 알림이 사라져, 이 행이 막으려던 "보이지 않는 에러"로 되돌아간다. - -hint bar는 오버레이(repo 입력·prefix armed·swap target)가 열리면 그 내용으로 먼저 `return` 하므로, 알림이 거기 있던 시절에는 오버레이가 열린 동안 어떤 에러도 보이지 않았다. 알림을 별도 행으로 분리하면서 이 경합 자체가 사라졌다. 지금 hint 행에 알림이 뜨는 경우는 repo 다이얼로그가 열려 입력이 notice 행을 차지한 동안뿐이고, 그때도 알림은 legend보다 앞선 우선순위로 항상 보인다. +notice 만료는 메시지 문자열이 아니라 `NoticeKind`로 판정한다. 같은 kind의 성공만 해당 notice를 지우며, 앱이 처리한 입력만 dismiss한다. PTY passthrough key는 dismiss하지 않아 사용자가 타이핑을 재개했다고 오류가 사라지지 않는다. ← [Architecture index](../architecture.md) diff --git a/docs/architecture/web.md b/docs/architecture/web.md index d18fc776..09b4a4fd 100644 --- a/docs/architecture/web.md +++ b/docs/architecture/web.md @@ -1,152 +1,60 @@ # Web Surface -브라우저 표면은 두 층으로 나뉜다. `src/web/common/`은 무엇을 서빙하는지 모르는 프리미티브 (인증·HTTP 프레이밍·SSE·연결 회계)이고, `src/web/viewer/` + `viewer-ui/`가 실제 뷰어다. 뷰어는 TUI와 **같은 데이터 계층을 읽어 DOM으로 렌더하는 두 번째 프론트엔드**로, `App`/`ui`/`input`을 전혀 참조하지 않아 TUI 없이도(`nightcrow serve`) 동작하고 TUI와 별도 포트·쿠키·비밀번호를 쓴다. +web surface는 `src/web/common/`의 인증·HTTP·SSE·connection primitive와 `src/web/viewer/` + `viewer-ui/`의 저장소 viewer로 나뉜다. viewer는 TUI의 `App`/`ui`/`input`을 참조하지 않고 session operation·runtime·terminal hub를 사용하므로 TUI 없이도 인자 없는 `nightcrow` daemon 실행에서 함께 동작한다. -## 공용 웹 계층 (`src/web/common/`) +## Common web boundary -git 데이터도 터미널도 전혀 모르는 계층이며, 웹 표면이 하나 더 생기더라도 공유는 정확히 여기까지다. +- password는 Argon2 PHC로 검증하고, 로그인 시도는 process-wide 2회/분·14회/시간으로 제한한다. 성공하면 httpOnly·SameSite=Strict session cookie를 발급한다. cookie name은 서버별로 분리한다. +- session token은 opaque random value로 `~/.nightcrow/sessions`에 저장하고 로그아웃 때 server-side revoke한다. configured TTL은 기존 token에도 적용하며 만료 token sweep은 load/write 시 수행한다. Unix 파일은 owner-only(0600)이고 Windows 권한 seam은 no-op이므로 상태 디렉터리 접근을 운영자가 보호한다. +- 기본 bind는 loopback이고 TLS는 제공하지 않는다. 원격 접근은 SSH tunnel 또는 TLS reverse proxy를 전제로 한다. 연결·header·body·WebSocket message·SSE payload는 bounded하고, connection slot은 handler 종료 시 `Drop`으로 반환한다. +- SSE는 전용 stream이 head를 직접 쓰고 매 event flush한다. event name에 newline을 허용하지 않으며 쓰기 오류를 명시적으로 전파한다. -- **인증 (`common/auth.rs`)**: 비밀번호를 Argon2로 검증한다(code-server와 동일 방식). 평문 `password`는 시작 시 메모리에서 해시하고, `hashed_password`(PHC)가 있으면 그쪽이 우선한다. 로그인은 rate-limit(2/분 + 14/시간)되고 성공 시 httpOnly 세션 쿠키를 발급한다. **쿠키 이름은 서버가 정한다** — 같은 호스트의 다른 서버가 여기서 발급한 세션으로 인증되면 안 되므로 이름을 이 계층에 두지 않는다. 기본 바인딩은 loopback이며 **TLS는 없다** — 원격은 SSH 터널/리버스 프록시로 감싼다. 서버 활성 시 비밀번호가 없으면 랜덤 생성해 config에 기록하고(주석 보존) 시작 시 1회 출력한다. -- **세션 영속 (`common/sessions.rs`)**: 세션 토큰은 `~/.nightcrow/sessions` 파일에 영속화되어 데몬 재시작 후에도 살아남는다. **수명은 스토어가 생성될 때 받는다** — 얼마나 오래 로그인을 유지할지는 데몬을 돌리는 사람의 판단이므로 상수가 아니라 `[web_viewer] session_ttl_hours`에서 온다. 쿠키 `Max-Age`도 같은 값에서 나오되 400일에서 잘린다(RFC 6265bis의 상한, Chrome 104+가 강제). `None`(= `session_ttl_hours = 0`)이면 토큰은 스스로 만료하지 않고 `never`로 기록된다 — 숫자가 아니므로 이 형식을 모르는 빌드는 그 줄을 파싱 실패로 버린다. **수명을 줄이면 이미 발급된 토큰에도 닿는다**: load가 각 만료를 `now + ttl`로 캡하되 낮추는 방향으로만 움직이고(`clamp`), 무언가 잘렸으면 **그 자리에서 파일에 쓴다**. 안 그러면 파일이 옛 만료를 계속 말하고, 수명보다 자주 재시작하는 세션은 매번 새 수명을 받아 조인 정책이 영영 적용되지 않는다. **만료 정리는 스케줄러 없이 쓰기에 얹는다**(`sweep`): 파일에 쓸 때마다 전체를 훑고, 로드할 때 이미 지난 것을 버린다. `is_valid`의 지연 제거만으로는 **아무도 다시 묻지 않는 토큰**이 남는다 — 그 쿠키를 든 브라우저가 돌아오지 않으면 그 토큰은 영영 검사되지 않아, 데몬이 오래 살수록 파일이 로그인시킬 수 없는 세션을 세게 된다. 파일이 바뀌는 순간은 로그인·로그아웃·만료 토큰 제시 뿐이라 타이머를 둘 이유가 없다. **로그아웃은 서버측 취소** — `revoke`가 메모리와 디스크 양쪽에서 토큰을 지우므로, 쿠키를 지우는 것만으로는 인증이 유지되지 않는다. 파일은 owner-only 권한(0o600)으로 생성되며(`platform::fs` seam), Windows에서는 대응 API가 없어 no-op이므로 운영자가 상태 디렉토리 위치로 통제한다. 파일이 손상되거나 읽을 수 없으면 빈 스토어로 시작한다 — 세션 파일 문제가 서버 시작을 막아서는 안 된다. -- **스트리밍 응답 (`common/sse.rs`)**: `http::response`는 항상 `Content-Length`와 `Connection: close`를 실으므로 소켓을 열어 둔 채 이벤트를 덧붙일 경로가 없다. `SseStream`은 자기 헤드를 직접 쓰고 그 시점부터 연결을 소유한다. 매 쓰기마다 flush하며(버퍼에 남은 이벤트는 전달된 이벤트가 아니다) 쓰기 실패를 그대로 전파한다 — 닫힌 탭은 다음 쓰기가 실패할 때만 알 수 있다. event 이름에 개행이 있으면 거부한다(SSE 필드 위조 가능). data는 개행마다 `data:` 라인으로 쪼개므로 별도 방어가 필요 없다. -- **연결 회계 (`common/conn.rs`)**: 연결마다 스레드가 하나씩 붙으므로 상한이 없으면 포트에 닿을 수 있는 누구나 프로세스를 고갈시킬 수 있다. 상한 초과분은 accept 루프에서 소켓을 닫는다(거기서 503을 쓰면 멈춘 클라이언트 하나가 뒤의 모든 연결을 막는다). 슬롯은 `ConnectionSlot`의 `Drop`으로 반납돼 장수하는 WS handler와 조기 에러 반환 양쪽에서 새지 않는다. +## Request and repository gates -## 서버 (`src/web/viewer/`) +일반 요청은 다음 순서를 지킨다. -**요청 처리 순서가 설계다**(`viewer/server/`): ① Host → ② Origin → ③ 정적 번들(인증 불필요) → ④ 인증 → ⑤ 저장소 조회 → ⑥ 경로 검증. +```text +Host → Origin → static bundle → authentication → repository lookup → path gate → handler +``` -- Host 검사가 Origin보다 앞이자 별개인 이유: `origin_allowed`는 Origin과 Host가 *일치한다*는 것만 증명하는데, DNS rebinding 공격자는 둘 다 통제하므로 그 조건을 자명하게 만족시킨다. loopback 바인딩일 때 non-loopback Host를 거부해야 rebinding으로 얻는 same-origin 발판이 막힌다. -- 인증을 조회보다 **먼저** 하는 이유는, 그러지 않으면 미인증 클라이언트가 404와 401을 비교해 존재하는 repo id를 열거할 수 있기 때문이다. 정적 번들이 인증 앞에 오는 이유는 그것이 로그인 폼을 그리는 주체이기 때문 — 게이팅하면 로그인할 방법 자체가 사라진다. -- **경로 검증은 헬퍼에서** 한다. 라우트마다 쓰면 빠뜨린다: 실제로 `/api/diff`가 `../../etc/passwd`를 받아들였다. `load_file_diff`가 경로를 파일이 아니라 git pathspec으로 넘겨 검증기에 닿지 않았고 공격자의 경로를 그대로 되돌려줬다. **라우트가 "어떤 로더를 호출하느냐"에 따라 우연히 안전해서는 안 된다.** -- **`/api/preview`는 "API는 저장소에 *대해* 답하지, 저장소 파일*로* 답하지 않는다" 원칙의 유일한 예외다.** HTML 미리보기가 스크립트를 실행하려면 문서에 자기만의 CSP가 있어야 하는데(`srcdoc`은 embedder 정책을 상속해 `script-src 'self'`가 인라인 스크립트를 막는다), 정책은 네트워크 응답만 실을 수 있다. 응답의 `sandbox allow-scripts`가 문서를 opaque origin으로 만들고(쿠키 없음, 요청은 전부 비인증에 `Origin: null`), `connect-src 'none'`이 나가는 채널을 전부 닫는다 — 무엇을 열고 무엇을 닫는지는 `server/preview.rs` 모듈 doc이 기준. 경로는 `/api/file`과 같은 로더·게이트를 지나고, iframe 쪽 `sandbox` 속성이 헤더와 교차 적용되는 이중 레이어다. **게이트는 둘이고, 나뉘는 기준은 그 경로로 무엇을 하느냐다.** `with_repo_git_path`는 경로를 **git에게 넘길 때** 쓰고 `validate_commit_path`로 검증한다 — 탈출·`.git`·NUL을 거부하는 순수 문자열 판정이며 **파일시스템을 보지 않는다**. `with_repo`는 이 프로세스가 **파일을 열 때** 쓰고 `resolve_in_workdir`로 그 위에 **심링크 거부와 존재 확인**을 더한다. **탈출 방지는 전부 앞쪽에 있다** — 뒤쪽이 더하는 것은 열려는 파일을 지키는 것뿐이다. 그래서 앞쪽은 파일시스템이 이름을 어떻게 읽는지까지 알아야 한다. **요청한 이름과 열리는 파일이 다를 수 있다**: Windows는 컴포넌트 끝의 점·공백을 버리고(`.. `가 부모), NTFS는 `.git`에 `GIT~1`이라는 8.3 이름을 주며 `::$…`를 스트림 접미사로 읽고(`.git::$INDEX_ALLOCATION`이 `.git`), HFS+는 특정 zero-width 문자를 무시한다(`.git`가 `.git`). 규칙을 판정하기 전에 이 재작성들을 먼저 되돌린다 (`effective_name`). git도 같은 것을 막지만 범위가 똑같지는 않다 — git은 콜론으로 나뉜 **모든** 구간을 검사하고 여기서는 첫 구간만 본다. 뒤 구간은 앞 이름에 매달린 스트림이지 디렉터리가 아니라서(`x:.git`은 `x`의 스트림) 차이가 닿는 곳이 없다. git은 심링크를 대상 *이름*을 담은 blob으로 다루므로(테스트로 고정: `path_gate.rs`) diff가 대상 파일 내용을 흘리지 않는다. **반대로 틀리는 것도 같은 사고다** — 게이트를 강한 쪽으로 잘못 붙여, 워킹트리에 없다는 이유로 **삭제된 파일의 diff가 400**이 되어 있었다. status 목록은 그 파일을 보여주는데 클릭하면 열리지 않았고, TUI는 게이트를 타지 않아 줄곧 정상이었다. 삭제는 탈출이 아니다. -- **diff 라우트의 `path`는 파일 하나를 가리킨다.** git pathspec은 디렉터리를 그 아래 전체의 prefix로 매칭하므로(`disable_pathspec_match`는 glob만 끈다) `path=src`가 `src` 아래 모든 변경을 `src`라는 이름표 하나로 답했다. 디렉터리인지 파일인지는 파일시스템을 봐야 알 수 있고 그것이 위에서 삭제된 파일을 막았던 바로 그 수단이므로, **수집 전에 거르지 않고 수집 후에 버린다** (`collect_hunks`의 `only`). 디렉터리는 200 + 빈 diff — 변경이 없는 파일과 같은 답이다. 에러로 구분할 수 없다: 어느 쪽이든 남는 것은 빈 결과뿐이다. -- **저장소는 opaque id로만 지정**한다(`src/session/catalog/`). 클라이언트가 디렉토리를 이름 붙일 수 없으므로 "어느 저장소인가"는 검증할 입력이 아니라 성공하거나 404가 되는 조회다. id는 프로세스 수명 동안 안정적이라 무관한 탭을 열고 닫아도 다른 id가 재배치되지 않는다. -- **카탈로그 경로는 경계에서 정규화**한다(`Catalog::normalized`). `set_paths`·`add_path`·`remove_path`· `reorder`가 모두 `resolve_repo_path`의 단일 철자를 사용한다. served 집합·`hidden`·`order`가 문자열로 동일성을 판단하므로, 중첩 디렉터리·심링크·끝 슬래시로 같은 worktree를 다시 열어도 탭이 중복되지 않는다. 기존 저장 상태의 옛 철자는 다음 쓰기에서 정규화되며, 일시적으로 active/pane preference가 기본값으로 돌아올 수는 있어도 영구 마이그레이션 코드를 두지는 않는다. -- **저장소별 런타임**(`src/session/runtime/`): `SnapshotChannel`은 단일 consumer `mpsc`라 자기 것을 띄운다. 스냅샷을 wire 페이로드로 한 번만 줄여 팬아웃한다. **팬아웃은 conflate**된다 — 느린 구독자는 최신 상태를 받지 밀린 과거를 재생하지 않는다(슬롯 1개 + 1-depth 병합 wakeup). 소켓 I/O 중 락을 잡지 않는다. 페이로드가 직전과 동일하면 발행하지 않는다: producer는 변화가 아니라 타이머로 tick하므로, 그러지 않으면 유휴 저장소가 매초 스트리밍하며 seq를 태워 "뭔가 바뀌었나"의 지표로 쓸 수 없게 된다. -- **터미널**(`src/session/terminal/`)은 **세션의 터미널이고 attach한 TUI가 보는 것과 같은 pane**이다 ([session.md](session.md#세션-공유-데몬--클라이언트) 참고). raw PTY 바이트를 그대로 보낸다 — **화면은 서버가 그리지 않는다**(xterm.js가 이미 에뮬레이터다). 허브가 스트림을 파싱하는 것은 딱 한 가지, 다른 방법으로는 알 수 없는 **pane의 모드**를 위해서다. 4바이트 LE pane id를 앞에 붙인 **바이너리 프레임** — PTY 읽기는 멀티바이트 시퀀스를 일상적으로 쪼개므로 JSON으로 조기 디코딩하면 브라우저가 재조립하기 전에 깨진다. **출력은 conflate하지 않고 큐잉**한다: 최신 status는 완결된 그림이지만 터미널 바이트는 하나만 빠져도 스트림이 깨지므로, 큐를 넘긴 클라이언트는 조용히 버리지 않고 끊는다. **끊는다는 것은 소켓까지다**(`Client::cut_off`) — broadcast 목록에서 빼는 것만으로는 절반이다. 연결 스레드는 `ws.read()`에 들어가 있어 큐가 비었다는 것을 알 방법이 없고, 그러면 그 페이지는 **연결된 채 멈춘 화면**을 들고 있게 된다. `onclose`에 달린 재접속이 발화하지 않으므로 스스로 복구하지도 못한다. 그래서 hub가 소켓 핸들을 하나 더 들고 `shutdown`한다 — 데몬이 attach한 클라이언트에 이미 쓰는 방법과 같다(`daemon::clients`). 데몬 브리지에는 핸들을 주지 않는데 (`None`), 그쪽 워커는 hub 세션을 폴링해 논블로킹으로 넘기므로 여기서 밀릴 수 없고 백프레셔는 그 바깥 계층이 처리하기 때문이다. -- **멈춘 것과 떠난 것을 구분한다**(`stalled_not_gone`). 양방향에 타임아웃이 걸려 있다 — 읽기는 10 ms 폴이라 한 스레드가 두 방향을 다 돌보고, 쓰기는 15초라 안 읽는 클라이언트가 스레드를 영영 물고 있지 못한다. 둘 다 macOS에서 `WouldBlock`, Linux에서 `TimedOut`으로 온다. 쓰기 쪽이 이것을 끊김으로 읽는 동안 **15초 동안 못 읽은 페이지는 소켓이 닫히고 모든 pane을 replay로 다시 세웠다** — 폰이 잠들거나 터널이 재협상하면 그렇게 된다. tungstenite도 같은 자리에 선을 긋는다: `Io` 에러는 "WouldBlock을 빼면" 치명적이고, 못 나간 프레임은 자기 write buffer에 남겨 다음 `write`/`flush`가 마저 보낸다. 그래서 이쪽은 기다리고, 기다리는 동안 **hub에서 프레임을 더 꺼내지 않는다** — 밀리는 것이 상한이 있는 곳에 쌓이게 두는 것이다. **정말 못 따라오는 클라이언트를 끊는 일은 위의 큐 상한 하나가 맡는다**. 상한이 두 곳에 있으면 느린 클라이언트는 둘 중 아무 쪽에나 걸린다. -- **연결이 끝나면 이유가 남는다.** 정상 종료·에러 종료는 INFO(`viewer: terminal socket ended`, 어느 동작 중이었는지를 `during`으로), 큐 초과로 **강제로 끊는 것은 WARN**이다. 페이지는 끊긴 소켓에 replay로 답하므로 사람이 그것을 보는데, 전에는 DEBUG뿐이라 기본 레벨에 아무 흔적이 없었다 — "가끔 뷰어가 튕긴다"를 코드에서 역산해야 했던 이유다. -- **자원 상한**(`limits.rs`)은 전부 `truncated`로 보고된다. 잘린 목록이 전체인 척하지 않는다. +Host를 Origin보다 먼저 검사해 loopback bind에서 DNS rebinding으로 내부 서비스가 노출되지 않게 한다. static bundle은 로그인 폼을 제공하므로 인증 전에도 서빙하지만 repository API·SSE·WebSocket은 인증 뒤에만 연다. repository lookup 전 인증을 끝내어 id enumeration을 막는다. -### PTY 크기는 확정된 값만 전달한다 (`usePaneSizes.ts`, `ServerMessage::Created`) +repository는 client가 만든 path가 아니라 process 수명 동안 안정적인 opaque id로 지정한다. catalog는 open/add/close/reorder 경계에서 canonical worktree path를 사용해 중복을 합치고, 목록·active id는 하나의 catalog snapshot에서 만든다. -리사이즈는 싼 메시지가 아니다 — 자식은 SIGWINCH를 받고 풀스크린 프로그램은 화면을 통째로 다시 그린다. 그래서 네 가지를 막는다. +파일을 여는 route는 공통 `with_repo`에서 `resolve_in_workdir`를 사용한다. git commit/pathspec으로만 넘기는 route는 `with_repo_git_path`에서 `validate_commit_path`를 사용한다. 두 gate 모두 traversal·절대 경로·NUL·`.git` 변형을 거부하며, 파일 gate는 symlink와 worktree containment도 확인한다. route마다 gate를 복제하지 않는다. 삭제된 historical path는 파일시스템 gate를 쓰지 않아 diff에서 허용된다. -1. **중간값을 보내지 않는다**: 브라우저는 최종 기하에 도달하기까지 여러 중간 상태를 지난다(두 번째 pane이 생기며 그리드가 쪼개짐, 웹폰트 로딩, 브레이크포인트 전환). `fit()`은 즉시 돌리되 — xterm 자기 버퍼만 reflow하고 선을 타지 않으므로 드래그가 매끄럽다 — 서버로 보내는 것만 레이아웃이 멈춘 뒤로 미룬다. -2. **`created`가 pane의 현재 크기를 싣는다**: pane의 크기를 아는 것은 그것을 정한 페이지뿐이라, 재접속한 클라이언트는 자기 크기를 보내야 했고 값이 같아도 자식은 한 번 다시 그렸다. 이제 클라이언트가 그 크기를 채택하므로 같은 레이아웃으로 리로드하면 리사이즈가 0번이다. **그 0번이 화면 복원을 대신 하고 있었다** — 이 전제를 잃어 실제 사고가 났고(자세한 것은 [session.md](session.md#스크롤백과-재접속)), 지금은 허브가 화면 자체를 들고 있다가 replay하므로 이 최적화는 그대로 유지된다 — 화면 복원이 resize에 얹혀 있지 않다. -3. **크기를 모르는 PTY는 만들지 않는다**: 접속하면 서버가 `pending`으로 "사이즈 대기 중인 startup 터미널 N개"를 알리고, 클라이언트가 그 pane들이 차지할 셀을 placeholder로 렌더해 **실제 DOM을 재서** `start`로 답한 뒤에야 PTY가 생긴다(`useStartupSizes`). 그리드 산술이 아니라 버려지는 xterm 하나를 그 셀에 열어 `proposeDimensions()`로 재는데, gap과 셀 헤더를 다시 유도하다 어긋나면 그 오차가 곧 이 핸드셰이크가 없애려던 "잘못된 크기로 태어남"이기 때문이다. **타임아웃은 두지 않는다** — 임의의 시간 상수는 기기마다 다른 브라우저 레이아웃 타이밍을 하나로 못 박는다. 측정 실패의 fallback은 **클라이언트**에 두고(실패했음을 아는 쪽이 거기다), `started` 플래그를 접속이 아니라 **`start` 도착 시점에 소비**한다 — 핸드셰이크 도중 끊긴 페이지가 터미널을 데려가지 못한다. 둘이 동시에 답하면 CAS로 첫 번째만 이겨 pane은 정확히 한 번 생긴다. -4. **replay가 몇 개를 줄지 미리 알린다**(`ServerMessage::Hello`의 `panes`): 2번이 약속하는 "리로드 리사이즈 0번"은 pane이 여러 개면 성립하지 않았다. replay는 pane을 하나씩(각각 뒤에 자기 스크롤백) 보내므로 클라이언트가 **가진 것만으로 그리드를 짰고**, 첫 pane이 패널 전체를 차지했다가 다음 pane이 오면 줄었다. 스크롤백이 커서 그 간격이 settle(60ms)을 넘으면 그 잘못된 그리드 크기가 실제 PTY로 나갔다가 되돌아왔다. 이제 `hello`가 올 pane 수를 싣고 클라이언트가 **최종 그리드를 처음부터 그려서**, 각 pane이 자기가 계속 쓸 셀로 도착한다. 개수는 정확하다 — `connect`가 락을 쥔 채 replay 전체를 큐잉하고 클라이언트 등록은 그 뒤라, 그 프레임들 사이에 broadcast가 끼어들 수 없다. 클라이언트는 pane 목록과 비교하지 않고 **하나씩 카운트를 깎는다**: replay 도중 pane이 죽으면 목표에 영영 도달하지 못해 빈 셀이 영구히 남는다. **zoom만은 개수로 예측할 수 없어**(최종 레이아웃이 pane 하나짜리다) 그 pane이 도착할 때까지 fit 자체를 보류한다 — 그 사이 그리드 셀에 맞춰진 pane은 zoom이 걸리는 순간 숨겨지면서 잘못된 크기를 그대로 안고 남는다. - -### 순서는 서버가 authoritative하다 +HTML preview는 검증된 파일만 sandboxed iframe으로 전달한다. 응답 CSP와 iframe `sandbox allow-scripts`를 함께 사용하고 `connect-src 'none'`으로 외부 연결을 닫는다. preview 문서의 navigation spoofing은 남은 위험으로 취급한다. -- **터미널 pane 순서**(`src/session/terminal/hub_layout.rs::reorder_panes`, `lib/paneOrder.ts`): 클라이언트가 pane 헤더를 드래그하면 원하는 전체 순서를 `reorder`로 보내고, hub가 살아있는 pane에 맞춰 재조정한 뒤 (`canonical_order`: 요청 순서 중 실재하는 id 먼저, 요청이 빠뜨린 live pane은 현재 순서로 뒤에, 모르는 id·중복은 버림) canonical 순서를 `reordered`로 **전 클라이언트에 broadcast**한다. 클라이언트는 낙관적으로 미리 바꾸지 않고 이 echo를 받아 반영해(`reconcileOrder`) 여러 기기가 한 순서로 수렴한다. 순서는 hub의 pane Vec에 살아 재접속 replay와 다른 기기가 자동으로 따라오고 디스크에는 쓰지 않는다. DnD는 HTML5 drag가 아니라 pointer 이벤트라(sidebar divider와 같은 선택) 폰 터치도 마우스와 동일하다. -- **어느 pane이 패널을 채우는지(zoom)**(`src/session/terminal/hub_zoom.rs`, `lib/zoom.ts`): 순서와 같은 자리·같은 이유다. 클라이언트는 `zoom`을 보낸 뒤 `zoomed` echo로만 반영하고, `connect`가 현재 zoom을 재생해 **새로고침한 페이지가 zoom한 채로 돌아온다** — 전에는 한 페이지의 `useState`에 살아 리로드마다 사라졌다. **프레임 순서가 계약이다**: `Created`보다 zoom 해제가 먼저, replay에서는 pane보다 zoom이 먼저 간다(각각 "새 pane이 zoom 뒤에 숨는 렌더"와 "grid로 정착했다가 전 PTY를 다시 리사이즈"를 막는다). 클라이언트는 무엇을 그릴지를 raw 값이 아니라 살아있는 pane 목록에서 파생시켜 (`renderedZoom`) 두 프레임 사이의 렌더에서 빈 패널이 나오지 않게 한다. **디스크에는 쓰지 않으며 쓸 수도 없다** — zoom은 pane을 가리키고 pane은 데몬의 자식이라 재시작하면 가리킬 대상이 없다. 패널 단위 maximize(`src/session/prefs/maximized.rs`)가 파일에 남는 것과의 차이가 이것이다. **attach한 TUI는 통보받고 무시한다**(`backend/hub.rs`): TUI의 zoom은 자기 활성 pane을 따르고 diff 뷰어까지 덮는 다른 질문이다. -- **프로젝트 탭 순서**(`src/session/catalog/`, `POST /api/repos/order`): 같은 모양이되 **전송 채널이 다르다** — repo 목록에는 전용 WebSocket이 없고 `/api/repos` 폴링뿐이라 broadcast 대신 REST로 갱신하고 다음 폴링이 그것을 받는다. **순서가 `rebuild`를 견디게** `Catalog`에 명시적 `order` overlay를 두어 `union_paths`가 base+added 자연 순서를 그 위에 정렬한다(순서에 없는 새 repo는 끝에). **닫으면 자리도 잊는다** — `remove_path`가 `hidden`에 넣을 때 `added`뿐 아니라 `base`·`order`에서도 지운다. 남겨두면 `add_path`가 이미 union에 있는 경로로 보고 append하지 않아 다시 연 탭이 방금 연 자리(끝)가 아니라 예전 자리로 되돌아가는데, `base`는 데몬 수명 동안 시작 시 한 번만 쓰이므로 그 기억은 스스로 사라지지 않는다. 폴링 스냅백은 세 겹으로 막는다: write-generation 가드(`repoOrderWrites`), 드래그 중 차단(`repoDraggingRef`), 그리고 **reorder POST가 in-flight/큐에 있는 동안 폴링이 순서를 채택하지 않는** pending 가드. 가드가 걸린 폴링은 서버 순서를 버리되 membership은 `reconcileOrder`로 받아들인다. **reorder POST는 클라이언트에서 직렬화**한다(한 번에 하나, 큐에는 최신 순서만) — 두 POST가 별도 커넥션이라 서버가 옛 요청을 나중에 커밋해 잘못된 순서로 영속할 수 있다. **남는 transient 하나**: 커밋 전 서버를 읽었지만 POST가 정착한 뒤 도착하는 폴링은 한 번 스냅백할 수 있다 — 자기교정되는 클래스라 서버 revision을 도입하지 않는다. -- **영속은 open/close와 같은 경계**를 따른다: headless `serve`(`persist=true`)면 `catalog.paths()`가 `workspace.json`의 탭 순서로 저장되고, TUI 동반 실행에서는 세션 한정이다(그 파일의 주인이 TUI다). 저장 시 `persist_workspace`는 `ws.active`를 인덱스가 아니라 **이전 활성 path 기준으로 재매핑**한다. **한계**: `serve`에 `--repo`를 명시하면 그 인자가 시작 순서를 지배해 저장된 재정렬이 재시작 때 덮인다. 또 `catalog.reorder`와 이어지는 `persist_workspace`(파일 IO)는 한 트랜잭션이 아니라 두 기기가 밀리초 안에 동시에 재정렬하면 파일이 한 박자 뒤처질 수 있다(라이브 catalog는 항상 정확). - -### 와이어 계약은 fixture로 고정한다 - -`dto/` → `viewer-ui/api.fixture.json` → `api.contract.test.ts`. Rust DTO와 TS interface가 같은 프로토콜을 손으로 두 번 적고 있어 한쪽만 고치면 화면이 조용히 빈 값으로 렌더된다. `PROTOCOL_VERSION`은 **의도적인** 호환성 단절을 알릴 뿐 실수를 잡지 못한다. 그래서 서버가 모든 페이로드의 예시를 fixture에 굽고(`UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`) 커밋한 뒤, TS 테스트가 그 JSON을 각 interface에 **대입**한다 — 검사는 `expect`가 아니라 타입 주석이 하고 `npm run build`의 `tsc -b`에서 실패한다. Rust 쪽 변경은 fixture diff로, TS 쪽 미반영은 컴파일 실패로 드러나는 **쌍**이 핵심이다. optional 필드는 있는 경우와 없는 경우를 모두 넣어 `skip_serializing_if`가 멈춘 것도 보이게 한다. **필드 추가는 TS 쪽에서 잡히지 않는다** — 그건 Rust fixture assertion이 잡는다. codegen(`ts-rs` 등)은 이 규모에서 얻는 게 fixture 한 장과 같아 쓰지 않는다. - -**`GET /api/repos`는 부트스트랩이다**(`ViewerBootstrapDto`). 저장소 목록에 `hot` 설정·`accent`· `now_ms`가 얹히면서 이 응답은 "클라이언트가 렌더를 시작하기 전에 서버와 맞춰야 하는 것 전부"가 됐다. 서버 전역 값에 각각 엔드포인트를 주지 않는 이유는 **클라이언트가 이미 3초마다 이걸 폴링하기 때문**이다. 반대로 `/api/status`에 얹지 않는 이유는 그쪽이 바이트 동일성으로 dedup되는 hot 스트림이라 설정이 낄 자리가 아니기 때문이다. - -### commit log 페이지네이션 (`/api/log`) - -클라이언트가 목록 끝에 다다르면 다음 페이지를 요청한다(`IntersectionObserver` 센티넬 — TUI의 prefetch에 대응). 페이지 크기는 `MAX_LOG_PAGE = 100`으로 TUI 기본값과 맞췄다. - -- **`skip`만으로 페이지를 나누지 않는다**: skip은 한 walk 안의 offset이라 페이지 사이에 커밋이 생기면 이후 offset이 전부 밀려 중복·누락이 생긴다 — 바로 아래 터미널 패널에서 커밋하는 것이 이 뷰어의 일상이다. 첫 응답이 walk 시작 커밋을 `head`로 실어 보내고 이후 요청은 `from=`로 고정한다. `from`이 잘못된 oid면 HEAD로 조용히 넘어가지 않고 **400**이다. -- **커서 방식은 채택하지 않았다**: 병합 히스토리에서 특정 커밋부터 walk하면 그 커밋의 *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. -- **"더 있는가"는 한 페이지보다 1개 더 요청해 판정한다**: 정확히 한 페이지를 가져와 같은 수로 capping하면 `truncated`가 참이 될 수 없어, 이전 구현은 항상 `false`를 보고했다. -- **`skip`에는 상한을 두지 않는다.** 순회량은 `skip + page`와 히스토리 길이 중 **작은 쪽**으로 이미 제한된다. 여기까지 온 클라이언트는 **이미 인증을 통과해 대화형 셸을 받은 상태**라, 그가 시킬 수 있는 일 중 revwalk 한 번은 가장 가벼운 축이다. 인증이 신뢰 경계이고 그 뒤에서 자원 사용을 다투는 것은 방어가 아니라 불편이다. **알려진 대가**: 페이지 i는 앞의 `i × MAX_LOG_PAGE`개를 다시 건너뛰므로 총비용이 히스토리 길이에 제곱으로 는다. anchor별 서버측 스냅샷 캐시로 없앨 수 있지만 "요청마다 상태가 없다"는 이 서버의 성질을 포기해야 하고, 스크롤로 닿는 깊이에서 페이지당 비용이 밀리초 단위라 그 교환은 하지 않았다. -- **자동 페이징은 렌더된 행 수에 반응한다**(`visibleCommits.length`): `IntersectionObserver`는 intersection *변화*만 보고하는데 페이지가 붙어도 센티넬이 제자리에 남을 수 있어 매 페이지마다 재관찰해야 한다. -- **필터가 걸린 동안에는 페이징을 멈춘다**: log 필터는 *로드된 것*을 좁히는 것이지 서버 검색이 아니므로 매치를 찾아 히스토리 전체를 걸어 들어가면 안 된다. "보이는 행 수" 기준만으로는 부족하다 — 페이지마다 매치가 하나라도 있으면 계속 재무장된다. 센티넬 자리에는 "로드된 N개를 필터 중"이라는 행을 그린다. -- **페이지 실패는 `logDone`이 아니라 `logStalled`다**: 둘을 합치면 일시적 오류가 히스토리의 끝으로 보고되고, footer 에러는 다음 폴링에 지워져 흔적조차 남지 않는다. 실패 시 retry 행을 그린다. -- **열린 로그는 HEAD를 따라간다** — status SSE가 나르는 `head`가 움직이면 fresh 첫 페이지를 받아 캐시에 접는다(`lib/logRefresh.ts`, TUI `apply_refresh_page`와 같은 규칙): 이전 head가 fresh 페이지에 남아 있고 그 아래가 캐시와 일치하면 새 커밋만 위에 붙이고(스크롤·drill-down 유지), 아니면(rebase·amend) fresh 페이지로 교체한다. prepend가 페이징을 깨지 않으려면 합쳐진 목록이 새 walk의 prefix여야 하는데, 판별 조건이 증명하는 것은 fresh 페이지가 보여주는 구간까지다 — 페이지 경계 아래는 안 움직였다고 신뢰하며, 병합의 side-branch 커밋이 경계 아래로 날짜순 정렬되면 그 신뢰가 깨져 깊은 페이지가 그 구간을 건너뛴다. TUI의 규칙이 거는 것과 같은 베팅이고(변경 없는 커밋 100개 아래의 rewrite여야 진다), 다음 탭 진입이 처음부터 다시 walk한다. refresh의 트리거는 "이전 head" 기준선이 아니라 **캐시가 walk된 head와 status head의 불일치**다 — 기준선 방식은 아직 아무것도 로드되지 않았을 때의 이동(빈 저장소의 첫 커밋, 초기 로드 비행 중의 커밋)을 잃는다. head는 3값이다: status 미도착(`undefined`)은 침묵이라 아무것도 안 하고, status가 head 없이 온 것(`null`)은 unborn HEAD의 보고라 목록을 비우는 refresh를 만든다(detached HEAD는 커밋 oid를 보고하므로 여기 해당하지 않는다). 첫 페이지가 착지할 때 status head와 어긋나 있으면 그 자리에서 refresh하고, refresh는 진행 중인 페이지 요청을 세대 올림으로 무효화한다(TUI의 fetch worker cancel과 같은 자리). refresh 실패는 불일치를 남겨 두므로 retry 행이 `logStalled`를 지우면 같은 비교가 다시 refresh를 만든다. 교체로 drill-down의 커밋이 목록에서 사라지면 drill-down은 자기 back 버튼과 같은 방식으로 닫힌다 — pane까지, pane이 보여주던 것이 그 커밋의 파일이므로. 탭을 떠나면 페이지가 버려지는 것은 그대로다. - -## 프론트엔드 (`viewer-ui/`) - -React 19 + TypeScript 7 + Vite 8 + Tailwind v4 + `@xterm/xterm` 6, 마크다운은 react-markdown (+remark-gfm, rehype-highlight). shadcn/ui는 쓰지 않는다 — 기본 톤이 TUI 밀도와 맞지 않아 덮어쓸 것이 더 많았다. `dist/`를 커밋해 `cargo install`에 Node를 요구하지 않는다(build.rs에서 npm을 부르면 Node 없는 설치가 전부 깨진다). CI가 재빌드해 커밋된 번들과 다르면 실패시킨다. - -`viewer-ui/src`는 화면 조립과 재사용 단위를 분리한다. `pages/`는 화면 조립, `components/`는 재사용 UI, `hooks/`는 UI·터미널·저장소 상태, `lib/`는 API 이외의 순수 도메인/레이아웃 유틸리티, `api/`는 서버 wire 계약과 HTTP 클라이언트, `styles/`는 전역 스타일이다. `pages/App.tsx`는 조립만 하고 `useAppViewModel`이 인증·프로젝트·clone을, `useRepoWorkspace`가 선택한 저장소의 status/log/pane을 소유한다. 서로만 주고받는 ref들을 App에 늘어놓으면 그 handshake가 조립 코드에 섞여 하나를 빠뜨렸을 때 원인이 보이지 않는다. `RepoShell`은 flat prop bag 대신 repository/sidebar/filePane/layout 계약을 받는다. 터미널 WebSocket 메시지는 `api/terminal.ts`가 decode/encode하는 단일 경계를 두고, 각 terminal hook은 검증된 discriminated union만 처리한다. wire 문자열을 hook마다 다시 해석하거나 조립하지 않는다. - -**테스트는 두 층이다.** `src/lib`의 순수 함수는 vitest 기본 환경(`node`)에서, React 훅은 `@testing-library/react`의 `renderHook`으로 DOM 환경에서 돈다 — DOM이 필요한 테스트 파일만 첫 줄 `// @vitest-environment happy-dom`로 스스로 선언하고, 나머지는 빠른 node에 남는다. 훅 층을 들인 계기는 view 기억 기능이다: 결함이 전부 훅 배선(복원↔기록 순서, 프로젝트 전환 레이스)에 있었는데 그 층에 테스트가 없어 리뷰로만 검증됐다. 환경은 **happy-dom** — jsdom보다 수 배 빠르고 Vitest 쪽 권장이며, 결정적으로 `window.matchMedia`를 구현한다(jsdom은 없어서 mock이 필요한데 `useTermKeyBar`·`termFont`가 그걸 읽는다). fidelity가 모자란 테스트는 jsdom을 들여 같은 파일 단위 방식으로 옮기면 된다. `@testing-library/react` 16은 `@testing-library/dom`을 peer로 요구해 함께 설치했다; `user-event`·`jest-dom`은 훅 테스트에 불필요해 컴포넌트 테스트를 시작할 때로 미뤘다. - -**큰 diff와 raw file은 viewport만 DOM에 둔다**(`lib/virtualWindow.ts`, `components/Virtual*`). 200행 이하는 브라우저의 native selection·find·접근성 트리를 그대로 얻도록 기존 전체 DOM 경로를 쓰고, 그보다 크면 20px 고정 행과 앞뒤 12행 overscan으로 windowing한다. 전체 높이는 spacer가 보존하므로 scrollbar와 저장한 `scrollTop`은 원본 행 수를 계속 나타낸다. 파일 anchor는 같은 행 높이로 직접 계산하고, diff의 모든 렌더 행은 자기 `data-hunk`를 가져 header가 viewport 밖이어도 whole-file 전환이 현재 hunk를 찾는다. Split은 넓은 화면에서 old/new 한 쌍을 같은 virtual row로 렌더해 세로 정렬을 보존하고, 좁은 화면에서는 hunk별 old 전체 뒤에 new 전체가 오도록 별도 row model을 쓴다. 20k fixture가 DOM 행 수를 계약으로 고정하고 initial/scroll/split 측정치를 함께 기록한다. - -**렌더 실패가 페이지를 가져가지 않게 한다**(`components/feedback/ErrorBoundary.tsx`, `lib/chunkError.ts`). boundary가 하나도 없으면 React는 어떤 렌더 에러에도 트리 전체를 unmount하고, 보는 사람 입장에서 그것은 **서버가 죽은 것과 구분되지 않는다.** 실제로 사라진 청크가 그 모양으로 왔다: on-demand 청크(markdown 렌더러·HTML preview·터미널 패널)는 content hash가 붙은 이름으로 받으므로, 빌드 이전에 열린 탭은 그 빌드가 지운 이름을 요구한다. debug에서는 `dist`를 디스크에서 읽으므로 재빌드만으로 그렇게 되고, release에서는 도는 프로세스가 자기 안의 번들을 계속 내주므로 `nightcrow update` **뒤 세션을 재시작해** 새 프로세스가 뜬 다음이다. - -- **그 자리에서 재시도할 수 없다.** HTML spec이 실패한 module fetch를 캐시하게 하므로(스크립트가 두 번 도는 것을 막기 위해) 같은 import는 그 페이지가 사는 동안 계속 같은 실패를 준다. 복구는 리로드뿐이고, 그래서 fallback이 내미는 것도 재시도가 아니라 리로드다. -- **판정은 `lib/chunkError.ts`의 순수 함수가 한다.** 로직을 테스트하기 가장 쉬운 곳(순수 함수)에 두고 boundary는 그것을 부르는 껍데기로 남긴다 — 훅 테스트 환경이 생긴 지금도 이 배치가 낫다. 판정은 **좁게** 잡는다 — 알아보지 못한 것은 일반 실패로 보고한다. 보고 있던 컴포넌트의 버그에 리로드를 권하는 것은 고쳐주는 것이 없으면서 읽던 자리만 잃게 한다. -- **왜 실패했는지는 알 수 없고, 아는 척하지 않는다.** 지워진 청크와 닿지 않는 서버는 어느 엔진에서도 같은 맨 `TypeError`로 온다 — SSH 터널 너머의 뷰어는 빌드보다 서버를 먼저 잃는 쪽이 드물지 않다. 그래서 함수 이름이 `isChunkLoadError`(원인이 아니라 사실)이고, 문구도 둘 다를 **가능성 순서대로** 말한 뒤 리로드에 판정을 맡긴다. "새 버전이 배포됐다"고 단정하면 서버가 죽은 순간에 사람을 엉뚱한 데로 보낸다. -- **fallback은 자기가 대신 선 자식의 가시성을 물려받아야 한다.** 자식의 className은 상속되지 않는데, 터미널 패널은 `md` 미만에서 선택된 뷰일 때만 화면에 있다. 그대로 두면 **실패가 그 패널을 등장시키는 경로**가 된다 — 고르지도 않은 영역이 에러 카드로 나타난다. boundary가 `className`을 받아 fallback에 그대로 걸고, display 클래스를 fallback 안에 박아두지 않는다 (`hidden md:flex`와 `flex`가 붙으면 승자는 호출부 의도가 아니라 CSS source order가 된다). -- **preview boundary는 파일로 key를 잡는다.** 렌더러가 서로 다른 청크라 하나를 잃은 것이 다른 하나에 대해 말해주는 바가 없는데, boundary는 remount 전까지 에러 상태를 붙들기 때문이다. -- Vite의 `vite:preloadError`를 따로 듣지 않는다. `preventDefault`하지 않으면 어차피 throw되어 import promise를 reject시키므로 boundary가 두 경로를 다 받는다 — 메커니즘을 하나로 둔다. - -**그래도 위는 사후다. 갱신은 미리 알린다**(`web/viewer/assets.rs`, `lib/viewerBuild.ts`). 청크 실패는 탭이 하필 그 청크를 요구할 때만 오고, preview도 새 패널도 열지 않는 탭은 **영영 모른 채** 옛 번들로 돈다 — 배포된 수정이 그 화면에만 도착하지 않는다. 그래서 서버가 자기 빌드를 이름 붙여 알린다. - -- **빌드 id는 `index.html`의 sha256 앞 4바이트다.** 코드는 전부 content hash가 파일명에 박혀 있고 셸이 그 이름들을 부르므로, 청크나 스타일시트의 어떤 변화든 셸을 바꾼다. 고정 이름으로 복사되는 `public/`은 여기 들어오지 않는다 — 아무도 import하지 않는 파일은 **도는 코드를 낡게 만들 수 없고**, 이 비교가 묻는 것은 그것뿐이다. 매 호출마다 다시 읽는다 — `dist`를 디스크에서 읽는 debug 서버에서 **도는 데몬 밑의 재빌드**가 바로 이 기능이 잡으려는 경우다. 빌드를 구분하는 값이지 인증하는 값이 아니라 4바이트로 충분하다. -- **비교의 한쪽은 문서에 도장으로 박는다.** 서버가 셸을 내줄 때 ``를 head에 끼워 넣는다. 페이지가 "내가 어느 빌드냐"를 **응답에서 추론하면 틀린다**: 로그인 화면에 머무는 동안 배포되면 첫 성공 응답이 이미 새 빌드라, 옛 번들을 돌리면서 새 빌드를 자기 것으로 기록하고 영영 알리지 않는다. 도장은 **박히는 파일에서 유도되므로 그 파일의 일부가 될 수 없다** — id는 저장된 바이트의 해시이고, 내주는 바이트는 거기에 태그가 더해진 것이다. -- **다른 한쪽은 이미 도는 폴링에 얹는다**(`ViewerBootstrap.viewer_build`). 3초 폴링이 이미 세션 전역을 나르는 통로라 엔드포인트를 새로 열지 않는다. -- **자동 리로드는 하지 않는다.** 터미널에 입력 중인 탭을 페이지가 스스로 날리는 것은 한 빌드 뒤진 것보다 나쁘다. 알림은 sticky 토스트로 남고 Reload 버튼을 함께 낸다 — sticky는 사건이 아니라 아직 참인 **상태**를 말하므로, 타임아웃으로도 뒤이은 에러 토스트에도 밀려나지 않는다 (`lib/toast.ts`의 `trim`). - -**pane 안의 프로그램이 클립보드를 채운다**(`lib/osc52.ts`, `lib/paneClipboard.ts`). PTY 건너편의 프로그램에는 읽는 사람에게 닿는 클립보드가 없다 — Claude Code의 `/copy`가 함께 부르는 `pbcopy`는 세션을 호스팅하는 기계에 쓰므로, 다른 데서 연 뷰어에서는 아무도 꺼낼 수 없는 곳에 들어간다. OSC 52는 출력과 함께 흘러 **출력이 보이는 곳에서 끝나는** 유일한 경로다. - -- **처리하지 않으면 조용히 사라진다.** 프로그램은 시퀀스를 내보낸 것과 같은 분기에서 "Copied to clipboard (63 characters)"를 찍으므로, 성공 여부를 되묻지 않는다. 즉 무시하는 터미널은 읽는 사람에게 **확인 문구와 그대로인 클립보드**를 남긴다. TUI는 같은 기계라 `pbcopy`로 멀쩡히 되므로, 이것은 viewer에만 뚫린 구멍이었다. -- **`@xterm/addon-clipboard`를 쓰지 않는다.** 그 addon이 하는 일은 OSC 핸들러 등록·selection 파싱·base64 디코드·provider 호출인데, 기본 provider가 `navigator.clipboard`뿐이라 아래 이유로 어차피 우리 provider가 필요하다. 남는 것을 위해 `js-base64` transitive dependency와 xterm 6 peer 미선언을 떠안는 대신 `parser.registerOscHandler(52, …)`로 직접 받는다. 디코드는 `atob` + `TextDecoder`로 되고, **파싱이 순수 함수로 남아 vitest 기본 환경(node)에서 검증된다** — addon을 썼으면 테스트할 수 있는 것은 provider 껍데기뿐이었다. -- **읽기 질의(`c;?`)에는 답하지 않는다.** 답하면 읽는 사람이 마지막으로 복사한 것 — 비밀번호, 토큰 — 이 **터미널 입력으로** pane 안의 프로그램에게 간다. **쓰기 허용은 귀결이 아니라 거래다**: "pane에 닿으면 이미 호스트 셸 권한"에서 따라 나오지 않는다. 덮이는 클립보드는 pane이 도는 기계가 아니라 **보고 있는 기기의 것**이고, 그 사람이 아무 데서나 마지막으로 복사한 것을 담고 있다. 얻는 것은 pane의 복사가 도착한다는 기능 자체고, 내주는 것은 프로그램이 보지 않고 붙여넣을 클립보드를 바꿀 수 있다는 것이다. 이 쪽은 어느 터미널 에뮬레이터나 같게 정하고, 갈리는 것은 읽기다 — 여기서는 엄격한 쪽을 택했다. 빈 데이터(클립보드 비우기)도 같이 버린다. -- **selection 매핑은 명세를 그대로 읽지 않는다.** 페이지의 클립보드는 하나뿐이라 `c`와 `s`, 그리고 생략(명세상 `s0`)이 모두 그 하나로 간다 — 의도된 손실 매핑이다. 대응물이 없는 `p`/`q`/cut buffer는 그리로 **넓혀 주지 않고 버린다**: 가운데 클릭 버퍼를 달라고 한 프로그램이 읽는 사람의 클립보드를 덮어도 좋다고 한 것은 아니다. 정의되지 않은 글자가 섞이면 selection이 아니므로 거절한다(`cX`의 `c` 하나를 근거로 삼지 않는다). -- **쓸 수 있는지는 예측하지 않고 시도해서 안다.** `navigator.clipboard`는 보안 컨텍스트에만 있어 평문 `http://`로 연 뷰어(Tailscale 주소, LAN IP — 원격에서 여는 방식의 대부분)에는 아예 없다. 어느 규칙이 걸리는지 맞히는 대신 써 보고 실패를 읽는다. **그래서 평문에서는 fallback이 드문 경로가 아니라 유일한 경로다** — 숨은 textarea를 만들어 선택하고 `execCommand("copy")`를 부르며, 브라우저는 문서에 포커스가 있으면 gesture 없이도 대개 허용한다. 버튼이 한 번도 안 뜨는 이유가 이것이다. **대가는 그 순간의 blur다**: 선택에는 포커스가 필요하고 `execCommand`에는 선택이 필요하다. 포커스는 되돌리므로 이미 확정된 입력은 무사하지만, 그 찰나에 조립 중이던 IME 음절은 잃는다 — 다른 pane의 프로그램이 하필 그때 복사하면 한글 한 글자가 날아간다. 여기서 피할 방법이 없어 한계로 남긴다. -- **거절당하면 없는 것이 press이므로 버튼으로 내민다.** sticky 토스트의 Copy가 그 press다. 누르면 **그 시점에 걸려 있는 텍스트**를 복사하고, 그것이 건너갔을 때만 토스트를 내린다 — 쓰기가 즉시가 아니라, 이전 press가 진행 중일 때 새 복사가 들어오면 토스트가 가리키는 대상이 바뀌기 때문이다. 옛 결과로 내리면 **한 번도 건너간 적 없는 텍스트의 알림을 치우게 된다**. 누른 뒤에도 실패하면 토스트는 그대로 서 있는다 — 건너가지 않은 복사는 사건이 아니라 아직 참인 상태다. - -### 서버 저장 preference - -- **accent는 세션의 것이고 브라우저는 그것을 칠한다**(`hooks/ui/theme.ts`). 헤더 스와치가 TUI의 ` p`와 같은 순서로 5색을 순환한다. 브라우저에는 ratatui 팔레트 대응물이 없어 hex를 고정하는데, 눈대중이 아니라 기존 amber `#d9a441`(OKLCH L=0.751 C=0.130 h=79.8)의 **명도·채도를 유지한 채 hue만 돌려** 파생시킨다 — 어느 프리셋을 골라도 ink 스케일 위에서 가독성이 같다. 적용은 root의 `--color-accent` 오버라이드 하나로 끝난다. 저장은 `~/.nightcrow/viewer.json` (`src/session/prefs/`), **저장소별이 아니라 세션 전역**이다: 뷰어는 여러 기기에서 열리고, repo id는 프로세스 수명 동안만 안정적이라 저장소별 키는 재시작마다 사라진다. 전달은 3초 `/api/repos` 폴링에 얹고 쓰기는 `POST /api/prefs`(cross-site가 트리거할 수 없도록 GET이 아닌 POST, 인증 뒤). 순서 문제는 `useViewerPrefs`가 로컬 변경 횟수를 세어 자기보다 오래된 응답의 accent만 버리는 것으로 막는다. localStorage는 **첫 페인트 캐시**로만 남는다: CSP가 인라인 스크립트를 막아 (`script-src 'self'`) 번들 실행 전에는 칠할 수 없는데 폴링 왕복까지 기다리면 매 로드마다 기본 amber가 번쩍인다. **이 값은 TUI의 것이기도 하다** — 경계와 뒤집은 이유는 [session.md](session.md#세션-공유-데몬--클라이언트). -- **마지막으로 보던 프로젝트를 서버가 기억한다**(`prefs::active_repo`, `lib/activeRepo.ts`). **저장은 id가 아니라 worktree path다** — repo id는 프로세스 수명 동안만 안정적이라 재시작 뒤에는 아무것도 가리키지 않거나, 더 나쁘게는 *다른* 프로젝트를 가리킨다. 정작 이 기능이 필요한 순간이 재시작이다. 클라이언트는 path를 절대 보지 않는다(카탈로그의 불변식): 서버가 POST에서 id→path로, GET에서 path→id로 옮긴다. **목록과 활성 id는 한 스냅샷에서 뽑는다**(`list_with_active`) — 따로 읽으면 목록에 없는 id가 실려 나가고, 그것을 받은 클라이언트는 첫 탭으로 폴백한 뒤 그것을 기록해 기억을 영영 덮는다. 살아 있지 않은 id를 보내면 **400**이다. **채택 규칙은 accent·폭과 다르다**: 활성 프로젝트는 **우선순위 폴백**이라 이미 살아 있는 프로젝트를 보고 있는 페이지는 그대로 둔다 (`resolveActiveRepo`: 현재 선택 → 기억된 것 → 첫 탭). 폰에서 탭을 바꿨다고 노트북이 읽던 화면에서 끌려 나오면 안 된다. -- **닫으면 이웃 탭이 앞으로 온다**(`session::close_repo`의 `successor_of`) — 닫힌 것의 다음, 마지막이었으면 이전. 브라우저의 관례이고 TUI의 `workspace::close_at`이 이미 고르던 답이다. **닫을 때 세션이 명시적으로 기록한다**: 그러지 않으면 기억된 path가 해석 불가로 남고 각 화면이 자기 폴백으로 떨어져 — 브라우저는 자기가 가진 첫 repo, TUI는 세션이 보고한 첫 repo — 넷 중 셋째를 닫으면 다들 첫 탭으로 갔다. TUI의 이웃 선택은 그 직후 도착하는 세션 답에 덮여 **죽은 코드였다**. 세션이 답을 내는 지금은 둘이 같은 값을 읽으므로 자동으로 일치하고 그 깜빡임도 없다. **앞에 없던 프로젝트를 닫으면 활성은 그대로다** — 포커스는 사람이 있는 자리이지 집합이 정할 것이 아니다. 클라이언트도 같은 규칙을 낙관적으로 적용하는데(`lib/successor.ts`) 폴링이 3초라 그때까지 사람이 보고 있는 화면을 정해야 하기 때문이고, 규칙이 같으므로 도착한 답이 아무것도 바꾸지 않는다. **닫는 쪽이 동시에 focus를 옮긴 다른 클라이언트를 덮지는 않는다** (`set_active_repo_if`: **판단할 때 읽은 값이 아직 그대로일 때만** 쓴다. 닫는 프로젝트를 가리키고 있을 때가 아니라 — 아무것도 고르지 않은 세션은 그 값이 비어 있고 활성은 폴백이 정한 것이라, 닫는 경로와 비교하면 후임을 아예 기록하지 못한다. prefs의 잠긴 read-modify-write 안에서 비교하므로 다른 focus 쓰기에 대해 원자적이다). 다만 그것이 보장하는 것은 *닫기가* 덮지 않는다는 것이지 그 focus가 살아남는다는 것이 아니다 — 브라우저는 닫은 뒤 자기가 착지한 곳을 기록하고(`useRepoPoll`의 단일 지점), 그 쓰기는 닫기의 결정이 아니라 클라이언트가 "나는 여기 있다"고 말하는 것이라 다른 전환과 같이 마지막 주장이 이긴다. TUI의 닫기는 뒤이어 아무것도 주장하지 않으므로 그쪽에서는 온전히 지켜진다. 그래서 write-generation 가드도 필요 없다. **쓰기는 선택이 정해지는 한 곳**(`useRepoPoll`의 effect)에서만 하고 **클라이언트에서 직렬화**한다(`lib/serialWrite.ts`) — accent·폭은 도착 역전을 감수하지만(다음 폴링이 UI를 서버 값으로 되돌린다) 활성 프로젝트는 폴링이 UI를 되돌리지 않으므로 **화면과 서버가 조용히 갈라진 채 다음 로드까지 간다**. 직렬화의 대가로 **`send`는 반드시 끝나야 하므로** 이 쓰기에만 `AbortSignal.timeout`을 건다. **폴이 채택시킨 값은 되쓰지 않는다** — 페이지 둘이 열려 있으면 각자가 상대의 쓰기를 따라간 뒤 그 값을 되써서, 활성 프로젝트가 초 단위로 진동하며 두 페이지의 터미널 패널을 계속 부쉈다(2026-08-18 관측; 패널은 repo가 바뀔 때마다 소켓과 xterm을 새로 만든다). 다만 "이미 서버 값이면 건너뛰기"는 아니다 — 그건 실제로 시도했다 되돌렸다(A→B→A를 3초 안에 하면 서버에 B가 남는다). 폴이 채택시킨 **그 값 하나**를 기억해두고 직접 전환이 쓰일 때 지우므로, 따라갔던 프로젝트로 손수 돌아오는 것은 여전히 기록된다. 서버가 아무것도 기억하지 못해 첫 탭으로 떨어진 폴백도 채택이 아니라 이 페이지의 착지라 기록된다. localStorage 캐시는 쓰지 않는다. -- **사이드바 너비는 divider 드래그로 조절한다**(`hooks/ui/sidebar.ts`). 드래그 원점은 시작에 한 번만 재서 중간 re-layout이 원점을 옮기지 못하게 한다. 저장은 accent와 같은 서버 전역이고 첫 페인트 캐시로 localStorage도 쓴다. **저장값은 절대 `[280, 720]px`뿐**(서버·`adopt`·load 모두 clamp)이라 넓은 화면에서 정한 폭이 좁은 화면에서 잘려 사라지지 않는다. **뷰포트 50% 상한은 표시에만 건다** — grid track이 `min(px, 50vw)`라 창이 좁아지면 즉시 diff pane이 최소 절반을 지키고 넓히면 저장값까지 회복한다. 드래그 중에는 로컬 상태만 갱신하고 놓는 순간 한 번 POST하되, **가로로 유의미하게 (≥`SIDEBAR_DRAG_THRESHOLD_PX`) 움직였을 때만** 커밋한다(순수 클릭이 `50vw`로 잘린 값을 절대 저장값에 덮어쓰지 않도록). **더블클릭은 기본 폭(460)으로 복구**하되 뷰포트 캡이 아니라 절대 기본값을 저장한다. 더블클릭은 네이티브 `dblclick`이 아니라 pointer 핸들러 안에서 판정한다 — 드래그의 `preventDefault`가 합성 click을 삼킬 수 있어서다. divider는 md+ 2컬럼에서만 뜨고 pane maximize 시엔 숨는다. -- **터미널 패널 높이도 divider 드래그로 조절한다**(`lib/upperPct.ts`, `hooks/ui/upperPct.ts`, `components/terminal/PanelDivider.tsx`). **재야 하는 구간이 두 grid track에 걸쳐 있고 그 구간에 해당하는 element가 없어서** 위쪽 끝은 `
`, 아래쪽 끝은 터미널 `
`에서 각각 잰다(둘 다 드래그 시작에 한 번만). 제스처 자체는 사이드바와 **같은 `useDividerDrag`**를 쓰고 축과 측정만 다르다. 저장은 `viewer.json`의 `upper_pct`(`[20, 85]` clamp, 기본 55)이고, 퍼센트라 뷰포트 상한이 필요 없다. - - **사이드바 폭과 달리 TUI와 공유하지 않는다.** TUI에 대응 값이 있는데도(`config.layout.upper_pct`, 기본 55) accent처럼 세션 소유로 올리지 않은 이유가 셋이다. (1) 퍼센트는 40행 터미널과 1400px 창에서 서로 다른 것을 가리키므로 **수렴할 단일 답이 없다**. (2) 이 값이 지배하는 것처럼 보이는 PTY 크기는 이미 한 클라이언트가 정하므로, 비율을 공유하면 관전자의 **패널만 움직이고 그 안의 그리드는 그대로**여서 여백이나 잘림만 늘어난다. (3) "터미널에 화면을 얼마나 줄까"는 보고 있는 화면에 대한 질문이라 **fullscreen과 같은 계열**이다 — 이산 버전(maximize)이 클라이언트별인데 연속 버전을 공유로 두면 어긋난다. - - **divider는 앱 grid의 다섯 번째 자식이 될 수 없다.** 최상위 grid는 DOM 자식 순서에 걸린 auto-placement로 매 브레이크포인트에서 보이는 4개를 같은 track에 떨어뜨리므로, element를 하나 더 넣으면 나머지가 엉뚱한 track으로 밀린다. 그래서 터미널 패널 **안에서** 그 패널이 이미 그리는 `border-t` 위에 absolute로 얹는다. maximize 중과 `md` 미만에서는 렌더하지 않는다. -- **패널 최대화는 프로젝트별로 저장한다**(`src/session/prefs/maximized.rs`). "이 프로젝트의 화면을 어떻게 배치했나"는 **view state**이고 TUI는 그것을 세션 파일에 프로젝트별로 이미 들고 있었다. **TUI의 파일에 쓰지 않고 공유하지도 않는다** — `workspace.json`은 TUI가 붙어 있는 동안 TUI 소유이고, 40행 터미널의 최대화와 1400px 창의 최대화는 애초에 같은 답이 아니다. **키는 절대 경로**로 (`active_repo`와 같은 이유), 상한은 TUI와 같은 50개. **"아무것도 최대화 안 됨"은 항목의 부재로 표현한다** — 그게 압도적으로 흔한 상태라 저장하면 스쳐 지나간 프로젝트마다 "none" 한 줄이 남는다. 클라이언트 상태는 `useViewerPrefs`에 두는데, 현재 프로젝트를 만들어 내는 `useProjectTabs`보다 **위에서** 소유해야 하기 때문이다. localStorage 첫 페인트 캐시는 두지 않는다: 키가 repo id라 캐시된 맵은 재시작 후 엉뚱한 프로젝트를 가리킨다. -- **프로젝트를 다시 열면 마지막으로 보던 것을 연다**(`src/session/prefs/repo_view.rs`, `hooks/useRepoViewMemory.ts`). 저장하는 것은 TUI가 세션 파일에 들고 있는 것과 같다 — 탭 (status/log/tree), 열려 있던 파일(경로 + 커밋 + `diff`/`source` 어느 면), 트리의 펼침. **트리 커서는 저장하지 않는다** — 브라우저 트리에는 커서가 없고, 돌아갈 행은 파일이 열려 있는 행이라 `file`이 이미 그 이름을 대고 있다(TUI의 `tree_selected_path`가 가리키는 것도 같은 행이다). **파일은 `viewer.json`이지 `workspace.json`이 아니다**: 후자는 TUI가 종료할 때 자기 메모리 상태로 통째로 다시 쓰므로(`session::operations::persist_workspace`) 뷰어가 쓴 항목은 TUI가 한 번 뜨고 지는 것만으로 사라진다. 최대화와 같은 키(절대 경로)·같은 상한(50). - - **경로 검증은 저장하는 자리에서 한다**(`repo_view::sanitize`). 이 값은 다시 "열어라"는 요청이 되어 돌아오는데, 들어오는 문은 둘이다 — HTTP와 손으로 고친 `viewer.json`. 두 문에 각각 가드를 두면 규칙이 갈라지므로 안쪽 한 곳에서 거른다. HTTP가 답하는 것은 저장소가 표현 못 하는 것뿐이다 (모르는 repo·탭·면 → 400). - - **기록은 화면을 읽지 않고 "무엇을 요청했나"에서 나온다**(`useRepoViewMemory`의 `note*`, `useRepoWorkspace`의 opener 래퍼). 화면은 요청의 비동기 그림이라, 탭과 응답 사이에는 직전 것이나 빈 것이 떠 있고 둘 다 사람이 고른 것이 아니다. 화면을 읽으면 매 쓰기가 "지금 이 순간이 진짜인가"를 먼저 판정해야 하는데, 그 판정은 요청 중복·프로젝트 전환·실패·리렌더로 끝없이 갈라진다(이 기능이 리뷰에서 반복해 샌 자리가 정확히 거기다). 행동은 일어나는 순간 자기 뜻을 말하므로, `note`는 그 선택 자체를 받고 그 뒤 pane이 어떻게 되든 기록은 흔들리지 않는다. - - **그래서 복원은 아무것도 기록하지 않는다.** 이미 저장된 것을 되돌려 놓는 일이기 때문이다. 실패한 복원도 마찬가지라, 서버가 한 번 넘어진 것으로 기억이 지워지는 경로가 아예 없다 — 못 연 파일은 그대로 기억에 남아 다음에 다시 시도된다. - - **아무도 손대지 않은 화면에만 복원하고, 그 전에 한 선택은 버리지 않고 들고 있는다** (`pendingRef`). 피커로 연 프로젝트는 응답이 그것을 담기 전에 이미 조작 가능하다 — 그 창에서 무언가를 고른 사람은 기다리는 중이 아니라 고르는 중이므로, 그 선택을 모아 두었다가 응답이 오면 **저장돼 있던 것 위에 얹어** 기록하고 복원은 하지 않는다. 안 건드린 항목은 옛 답을 유지한다. 보관은 방문이 아니라 **프로젝트별**이다 — 답이 오기 전에 자리를 뜨는 건 마음이 바뀐 게 아니라서, 돌아오면 그때 반영되고 **화면도 그 값으로 복원된다**(지금 그 화면을 쓰고 있는 사람 위에만 복원하지 않는다). - - **`note`는 지금 들고 있는 값에 합친다**(ref로 읽는 `latest`), 이 렌더의 사본이 아니라. 한 tick에 선택이 둘일 수 있고(탭 전환은 탭 *그리고* 그 탭이 비우는 pane이다) 렌더 사본에 각각 합치면 뒤가 앞을 지운다. poll이 그 사이 옮겨놨을 때 옛 답이 되돌아오는 것도 같은 이유였다. - - **"없다"와 "아직 못 들었다"를 가른다**(`covers`). 응답은 기억이 있는 프로젝트만 싣기 때문에 맵에 없는 id는 둘 중 어느 쪽인지 말해주지 않는다. 그래서 **그 프로젝트를 담은 응답을 받은 뒤에야** 복원하고, 그 전에는 기록도 하지 않는다 — 피커로 방금 연 프로젝트는 poll보다 먼저 화면에 오르고, 그 사이 빈 화면을 적으면 열자마자 기억이 지워진다. 서버가 `last_view` 자체를 안 보내는 옛 버전이면 빈 맵으로 읽는다. - - **기억이 없어도 탭은 되돌린다**(`restoreTab(undefined)` = status). 안 그러면 자기 뷰가 없는 프로젝트가 **직전 프로젝트의 탭**을 물려받는다. - - **프로젝트가 바뀌면 화면 상태를 렌더 중에 초기화한다**(`useRepoWorkspace`의 `shownRepo`). effect로 미루면 pane과 탭이 **직전 프로젝트의 것**인 렌더가 한 번 생기고, 그걸 읽는 쪽마다 예외를 달아야 한다. React는 이 setState들을 커밋 전에 다시 렌더하므로 그 렌더 자체가 없어진다. - - **화면을 바꾸는 길은 모두 자기 선택을 남긴다** — opener, 탭 전환(그 탭이 비우는 pane까지), 커밋 파일 목록에서 나오는 `< log`, 트리의 토글과 검색 결과의 디렉터리 열기. 하나라도 빠지면 그 행동만 다음 방문에 되돌아오지 않는다. 이미 열려 있는 프로젝트를 다시 고르는 것은 선택이 아니므로 아무 일도 하지 않는다(예전에는 pane만 비워, 화면과 기록이 어긋났다). - - **log 탭의 커밋 전체 diff는 파일로 기억하지 않는다.** 여러 파일에 걸쳐 있어 어느 하나가 이름을 댈 수 없다 — `openCommit`/`openCommitFiles`는 `noteFile(null)`이다. - - **커밋에서 연 파일은 어느 면이었든 diff로 돌아온다.** 소스 면은 pane의 토글 한 번 거리이고, 그것만을 위한 opener는 패널에 없다. - - **복원이 여는 pane은 탭이 연 pane과 다르다**(`OpenOptions.restoring`): 폰의 뷰를 파일로 옮기지 않고(방금 연 프로젝트는 목록을 보여주는 게 맞다), 실패해도 토스트를 띄우지 않는다 — 아무도 지금 요청한 적이 없기 때문이다. 세션 만료(401)만 예외로 그대로 올려보낸다. - - **트리 펼침은 `Sidebar`가 복원한다.** 트리 캐시가 거기 살고 저장소로 keyed되어 있어서다 — 프로젝트가 바뀌면 통째로 unmount되는 것이 캐시가 섞이지 않는 이유이므로, 복원을 위해 위로 끌어올리지 않는다. 복원은 **집합을 그대로 심고**(`seedTreeExpanded`) 경로마다 조상을 펼치지 않는다: `withToggled`는 디렉터리를 접어도 그 안쪽 항목을 집합에 남기므로, 조상을 펼치는 방식은 사람이 접어둔 디렉터리를 도로 연다. 심는 시점은 **서버가 그 프로젝트를 말해준 뒤, 트리 탭을 볼 때**이고, 이미 이 프로젝트에서 무언가를 고른 사람 위에는 심지 않는다. 모양을 알리는 것도 **디렉터리를 누른 그 순간**이지 캐시를 지켜보다가가 아니다 — 캐시는 복원이 심는 것이기도 해서, 지켜보면 심은 것이 선택으로 기록되고 심기 전 빈 캐시는 기억을 덮는다. 목록을 못 받은 디렉터리는 접지도 알리지도 않는다. - - **클라이언트도 서버와 같은 수에서 자른다**(`MAX_TREE_EXPANDED` 200, 양쪽에 상수). 안 그러면 서버가 잘라 돌려주고 클라이언트가 다시 다 보내는 왕복이 poll마다 반복된다. - - **경계**: 프로젝트를 닫는 순간 날아간 기록은 잃는다(닫힌 저장소는 카탈로그에 없어 서버가 400). 401로 실패한 파일 복원은 재로그인 후 자동 재시도되지 않는다 — 다시 열거나 새로고침하면 된다. 트리는 로그인 화면이 사이드바를 unmount하므로 돌아오면 다시 심는다. 탭 복원이 effect라 한 박자 늦어, 트리 탭에 있다가 status가 기억된 프로젝트로 옮기면 그 프로젝트의 트리를 한 번 심는다. -- **폰 터치 타겟을 넓힌다**: 목록 행·사이드바 탭·pane 버튼·`ProjectMenu` 항목은 `md` 미만에서 세로 패딩과 히트 영역을 키우고 `md:`로 기존 밀도를 복원한다. hover가 안 먹는 터치를 위해 `active:` 상태를 병행한다. - -## 클론 (`src/git/clone.rs`, `web/viewer/clone_jobs/`, `server/clone_routes.rs`) - -폴더 피커가 보고 있는 디렉토리에 원격을 클론하고, 끝나면 그 경로를 repo로 연다. - -- **URL로 클론하는 것은 `git` 바이너리에 위임한다. libgit2를 쓰지 않는다** — 벤더링된 빌드에 SSH 전송이 없어(`libgit2-sys`가 `libssh2-sys`를 끌어오지 않음) 가장 흔한 `git@host:path`가 아예 해석되지 않고, credential helper·`insteadOf`·에이전트가 쥔 키도 libgit2는 모른다. 이것은 프로젝트가 피하는 "git 출력 파싱"이 아니다 — stdout을 읽지 않고 종료 상태와 실패 시 stderr만 본다. 대가로 런타임에 `git`이 PATH에 있어야 하는데, 그 여부를 시작 시 한 번 재서 `/api/repos`의 `can_clone`으로 실어 보내 클라이언트가 반드시 실패할 job을 시작하는 대신 폼을 비활성화한다(서버 실행 중 git을 설치하면 재시작 전까지 반영되지 않는다). 서버 쪽 검사는 그대로 남아 버튼은 UX일 뿐 유일한 방어가 아니다. -- **URL 스킴 화이트리스트는 보안 경계다**(`validate_clone_url`). git은 `ext::`를 **그 명령을 실행해서** 해석하므로 검증하지 않은 URL은 서버에서의 원격 코드 실행이다. **URL을 `--` 뒤 argv 항목으로 넘기는 것으로는 막히지 않는다** — 스킴은 인자 파싱이 끝난 뒤에 해석된다. 그래서 `https`/`http`/`ssh`/`git+ssh`와 scp 형식(`user@host:path`)만 통과시키고 `file://`와 로컬 경로도 뺀다(로컬 디렉토리는 피커로 이미 닿는다). **`git://`도 뺐다** — 인증도 암호화도 없어 경로 위의 누구든 임의 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 슬롯을 재시작까지 쥔다. -- 대상 디렉토리 이름은 **클라이언트가 주지 않고 URL에서 파생**하며 `mkdir`과 같은 규칙(단일 평범 세그먼트, 숨김 아님)을 통과해야 한다. 부모를 먼저 canonicalize하고 **목적지는 `exists()`로 검사하는 대신 `create_dir`로 선점한다** — 검사와 사용 사이에 심볼릭 링크가 끼어들 수 있고 git은 그걸 따라가 부모 밖에 쓴다. `create_dir`는 원자적이고 마지막 경로 요소의 링크를 따라가지 않는다. 실패한 클론은 그 디렉토리를 **재귀가 아니라 `remove_dir`로** 지운다 — 그 사이 다른 무언가가 그 경로를 차지했더라도 내용을 파괴할 수 없게. **비어 있지 않으면 남는다**: checkout 단계에서 실패하면 git은 저장소를 의도적으로 보존한다(`JUNK_LEAVE_REPO`). 남은 디렉토리가 같은 이름의 재시도를 막지만 그건 눈에 보이는 불편이고 남의 파일을 지우는 것은 아니다. -- **남는 한계 (수용)**: `create_dir` 성공 이후 `git`이 그 경로를 여는 사이에, 부모에 쓸 수 있는 로컬 프로세스가 목적지를 심볼릭 링크로 바꿔치면 git이 부모 밖에 쓸 수 있다. 경로가 아니라 열린 핸들로 작업해야 닫히는 창인데 `git`은 별도 프로세스라 경로로만 받는다. 같은 UID라면 새로운 권한이 아니지만 **부모가 공유 디렉토리(`/tmp` 등)면 다른 UID도 해당된다** — 즉 "이미 할 수 있는 일"이라는 논리는 같은 UID에만 성립한다. 공유 디렉토리를 부모로 고르지 않는 것으로 피한다. -- **클론은 자기를 시작한 요청보다 오래 산다**(`clone_jobs/`). `POST /api/clone`은 스레드를 띄우고 job id로 답한 뒤 클라이언트가 `GET /api/clone?job=`로 폴링한다. 연결이 끊겨도 클론은 취소되지 않는다 — 터미널 hub가 PTY를 유지하는 것과 같은 선택이다. **동시 클론은 하나로 제한**하고 판정과 등록을 **같은 락 안에서** 한다(`try_start`) — 밖에서 묻고 나중에 넣으면 병렬 요청이 저마다 빈 레지스트리를 보는 check-then-act 경합이 된다. 끝난 job은 다음 시작 때 정리하되 **running인 job은 절대 evict하지 않는다**. 정리된 job을 뒤늦게 폴링하면 404가 나는데 클라이언트는 이를 **네트워크 오류와 구분해 종료로 취급**한다(재시도하면 폼이 "Cloning…"에 영원히 걸린다). -- 인증이 필요한 원격에서 멈추지 않도록 `GIT_TERMINAL_PROMPT=0`으로 돌린다. 죽은 연결은 `http.lowSpeedLimit=1024`/`lowSpeedTime=60`이 끊는다 — 정책 문턱이라 정당하지만 극단적으로 느린 전송도 함께 끊긴다. **벽시계 타임아웃을 두지 않은 이유**는 그것이 "느린 것"과 "멈춘 것"을 전혀 구분하지 못하기 때문이다 — 큰 저장소는 정당하게 몇 십 분이 걸린다. ssh에는 `GIT_SSH_COMMAND`로 `ConnectTimeout`/`ServerAliveInterval`/`CountMax`를 건다. **남는 한계**: 이것들은 *멈춘* 것을 끊을 뿐 완료를 보장하는 상한이 아니다. 실패 메시지는 redact하지 않고 git의 마지막 줄을 그대로 보낸다 — "repository not found"는 사용자가 친 URL에 대한 원격의 말이지 서버 내부 정보가 아니다. -- **진행 중인 클론은 job id 없이도 찾을 수 있다**(`CloneJobs::running`). job id를 아는 것은 클론을 시작한 그 페이지뿐인데 그 페이지는 리로드되거나 닫힐 수 있다. `job` 없는 조회는 지금 running인 job의 id로(없으면 `null`로) 답한다 — 동시 클론이 하나이므로 모호하지 않다. `null`은 에러가 아니라 "붙을 것이 없다"는 명시적 답이다. 숫자로 파싱되지 않는 `job`은 여전히 400 — 오타가 조용히 "무엇이 돌고 있나"로 바뀌면 그 클라이언트는 남의 job에 붙는다. -- **클론 job의 주인은 폴더 피커가 아니라 그 위다**(`useClone`을 `useAppViewModel`에서 호출). 훅을 피커 안에서 부르면 다이얼로그를 닫는 순간 관측자가 unmount되어 완료 토스트도 실패 메시지도 없고 끝난 repo도 열리지 않는다. 피커는 `(부모 경로, URL)`을 올리기만 한다. **로그인 직후 진행 중인 job에 자동으로 붙는다** — 붙을 게 없거나 probe가 실패하면 조용히 넘어간다. - -## 알려진 잔여 위험 (수용 또는 후속) - -- **저장소 루트가 넓어질 수 있다.** 핸들러는 `Repository::discover`로 저장소를 열고 `repo.workdir()` 기준으로 경로를 푼다. `discover`는 상위로 올라가므로, 저장소가 아닌 디렉토리를 서빙하면 (`serve --repo ~/notes`, `$HOME`이 저장소일 때) 브라우징 루트가 `$HOME`으로 넓어진다. traversal은 여전히 불가능하지만 운영자가 지정한 범위보다 넓다. 후속으로 `entry.path`에서 workdir을 파생시켜야 한다. -- **로그인 rate limiter가 프로세스 전역**이라 미인증 요청 3회/분으로 정당한 사용자의 로그인을 잠글 수 있다. 단일 비밀번호 모델의 대가. -- **터미널은 클라이언트 간 격리가 없다.** 연결된 어느 클라이언트든 그 저장소의 아무 pane에 입력·리사이즈·종료할 수 있다. 단일 공유 비밀번호에서는 일관되지만 pane 소유권 개념이 없다는 뜻이다. -- **PTY는 연결이 끊겨도 회수되지 않는다**(재접속 시 세션 유지 목적). 저장소당 최대 8개가 프로세스 수명 동안 남는다. -- **세션 토큰은 디스크에 영속화된다.** `~/.nightcrow/sessions` 파일에 0o600 권한으로 저장되어 데몬 재시작 후에도 로그인이 유지된다. 기본 24시간 TTL이 있지만, 비루프백 바인딩에서 토큰이 평문 HTTP로 전송되므로 네트워크 경로상 노출 창이 "프로세스 종료까지"에서 "TTL 만료까지"로 넓어진다. `session_ttl_hours = 0`은 그 창을 로그아웃까지로 넓히므로 **비루프백 바인딩과 같이 쓸 설정이 아니다** — 그래도 막지 않는 것은 무엇이 위협인지 아는 쪽이 운영자이기 때문이고, 기본값이 24시간인 것도 같은 이유다(고르지 않은 사람은 고르지 않은 것이다). 원격 접속은 SSH 터널이나 TLS 프록시로 감싸야 한다. Windows에서는 파일 권한이 no-op이므로 상태 디렉토리 위치로 통제한다. -- **`Secure` 쿠키 플래그 없음.** loopback 기본값에서는 맞지만 `bind`를 바꾸면 평문 HTTP로 토큰이 나간다. -- **HTML 미리보기 프레임은 자기 자신을 다른 곳으로 이동시킬 수 있다.** `allow-scripts`를 준 이상 스크립트가 `location`으로 프레임을 외부 URL(자기 소스를 실어 — 그 소스는 파일 작성자가 이미 가진 것이다)이나 팬을 채우는 피싱 페이지로 옮기는 것은 CSP로 막을 수 없다(`connect-src`는 연결만, navigation은 아니다). 프레임은 opaque origin이라 세션·다른 저장소 파일·앱 DOM에는 닿지 못하므로 사용자 비밀은 새지 않는다 — 정적 HTML도 이미 가능한 in-frame UI spoofing과 같은 계열의 잔여 위험으로 수용한다. 세션을 겨냥한 두 경로(top-level 이동으로 앱 origin 실행, 프레임에서 `/logout` 자가 이동)는 브라우저가 `Sec-Fetch-Dest`를 보내는 origin에서 그 헤더로 닫힌다(`server/preview.rs`, `dispatch.rs`). 브라우저는 이 메타데이터를 신뢰 가능한 origin(HTTPS·localhost)에서만 보내므로, 평문 HTTP(LAN·Tailscale 주소로 붙는 폰)에서는 헤더가 없다 — 게이트는 fail-open이라 그 경로에선 미리보기가 raw로 깨지지 않고 실행 가능한 문서를 받으며, 세션을 막는 것은 응답의 CSP `sandbox`(opaque origin) 단독이다. 이는 어차피 이 두 이동이 먼저 부딪히는 벽이고, `Sec-Fetch` 게이트는 메타데이터가 있는 곳에 벽을 하나 더 세우는 덤이었다. 평문 HTTP 원격 접속은 원래 TLS 프록시로 감싸는 것을 전제한다(위 세션 토큰 항목). +## Runtime and terminal transport + +repository runtime의 status snapshot은 최신 payload만 필요하므로 byte-identical 값은 publish하지 않고 fan-out도 conflate한다. terminal output은 raw byte stream이라 conflate하지 않고 queue가 가득 찬 client는 socket 자체를 닫는다. WebSocket terminal binary frame은 4-byte little-endian pane id 뒤에 raw PTY bytes를 붙이고 control event는 JSON frame으로 보낸다. + +terminal connection은 읽기와 쓰기를 bounded polling으로 다루며, stalled write 중에는 hub queue에서 frame을 더 꺼내지 않는다. 완전히 따라오지 못한 client는 session terminal queue 상한에서 종료되고 screen/mode/since replay로 재접속한다. pane 입력·resize·close·reorder는 authenticated client가 session hub에 보내며, pane별 authorization은 제공하지 않는다. + +서버가 canonical pane order와 zoom을 결정한다. client는 요청을 낙관적으로 적용하지 않고 `reordered`/`zoomed` echo와 reconnect replay를 받아 수렴한다. pane order·zoom은 디스크에 저장하지 않는다. `Created`에는 pane의 현재 size/title을, 초기 replay에는 정확한 pane 수를 싣는다. client가 만든 resize는 settled geometry에서만 서버로 보내고, session owner가 확정한 `Resized`만 emulator에 적용한다. + +## Wire and log contract + +`GET /api/repos`는 repositories, hot config, session accent, server clock, clone availability와 viewer arrangement를 묶은 bootstrap이다. 모든 JSON response는 `Envelope`의 `PROTOCOL_VERSION`을 포함한다. Rust DTO와 TypeScript interface는 `viewer-ui/api.fixture.json` 및 `api.contract.test.ts`로 양쪽 예시를 고정한다. optional field의 present/absent fixture를 함께 유지한다. + +`/api/log`는 `MAX_LOG_PAGE = 100`과 `from= + skip`을 사용한다. `from`은 같은 revwalk의 anchor이므로 page 사이에 새 commit이 생겨도 offset이 흔들리지 않는다. cursor를 마지막 oid의 조상으로 삼지 않는다. `skip`은 history length보다 더 걷지 않으며, `page + 1`개를 읽어 `truncated`를 판정한다. filter 중에는 추가 page를 자동 요청하지 않고, page 실패는 retry 가능한 stalled 상태로 표시한다. status의 HEAD가 바뀌면 log cache를 fresh page와 generation으로 갱신한다. + +## Browser state and frontend + +`viewer.json`에 session accent·sidebar width·`upper_pct`와 project별 last view/maximize를 저장한다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다. + +React 화면은 page 조립, reusable components, hooks, pure `lib`, API/wire 모듈을 분리한다. terminal WebSocket decode/encode는 한 경계에서 discriminated union으로 검증한다. 큰 diff/raw file은 viewport와 overscan만 DOM에 두고, 작은 파일은 native selection·find·accessibility를 보존한다. ErrorBoundary는 lazy chunk 실패가 전체 page unmount로 보이지 않게 하며, server build id와 content-hashed bundle을 비교해 stale page를 reload시킨다. DOM hook 테스트는 happy-dom, pure utility는 node 환경에서 실행한다. 빌드된 `viewer-ui/dist`는 runtime에 포함되므로 Node 없는 `cargo install`도 동작해야 한다. + +## Clone + +clone은 credential helper·SSH transport를 지원하는 `git` binary에 위임한다. `https/http/ssh/git+ssh`와 scp-like `user@host:path`만 허용하며 `ext::`, `file://`, local path와 `git://`는 거부한다. URL은 길이/control-character를 검사하고 destination name은 URL에서 얻은 단일 평문 segment만 사용한다. destination directory는 `create_dir`로 선점한 뒤 실행해 check/use race를 줄이고, 실패 시 비어 있을 때만 `remove_dir`한다. + +동시 clone은 하나이고 job은 요청 연결보다 오래 산다. client는 job id를 polling하며 정리된 job은 404 종료로 처리한다. `GIT_TERMINAL_PROMPT=0`, SSH liveness option과 HTTP low-speed 정책을 사용한다. 이는 정체를 끊는 상한이지 완료를 보장하는 wall-clock deadline은 아니다. + +## Accepted residual risks + +- `Repository::discover`가 사용자가 열거나 복원한 디렉터리에서 상위로 올라가므로, 지정 경로가 저장소가 아니어도 상위 worktree를 찾아 viewer root가 의도보다 넓어질 수 있다. traversal은 막지만 운영자는 실제로 서빙할 worktree 범위를 확인해야 한다. +- 기본 bind 밖에서 TLS 없이 session cookie가 전송될 수 있고, `session_ttl_hours = 0`은 logout 전까지 token을 유지한다. 원격 사용은 TLS proxy/SSH tunnel과 restricted state directory를 사용한다. +- 하나의 authenticated session 안에서는 client 간 pane 입력·resize·close 격리가 없다. repository당 PTY 수와 queue 상한이 process 자원 폭주를 제한하지만 multi-user authorization은 별도 기능이 아니다. ← [Architecture index](../architecture.md) diff --git a/docs/configuration.md b/docs/configuration.md index 1630a67b..827f6207 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,178 +1,98 @@ # Configuration -Config file: `~/.nightcrow/config.toml` (all fields optional, defaults shown). nightcrow runs on built-in defaults when the file is absent and never creates it on its own. To get a starter file, run: +nightcrow reads `~/.nightcrow/config.toml`. Every field is optional and omitted fields use the defaults below. A first run uses those defaults, then writes a generated web-viewer password to the config unless a password or hash is already configured. Run `nightcrow init` to create the complete commented starter; `nightcrow init --force` replaces an existing file. -```bash -nightcrow init # writes a commented ~/.nightcrow/config.toml -nightcrow init --force # overwrite an existing file -``` - -`init` leaves an existing config untouched unless `--force` is passed. - -## `[layout]` - -```toml -[layout] -upper_pct = 55 # vertical % for the diff panel (1–99) — the TUI's own; the - # viewer keeps a separate dragged value in viewer.json -file_list_pct = 25 # horizontal % of upper panel for the file list (1–99) -``` - -## `[theme]` - -```toml -[theme] -name = "yellow" # accent a session starts with, before anyone picks one: - # "yellow" | "cyan" | "green" | "magenta" | "blue" -``` +## Session and client settings -## `[input]` +| Table | Fields and defaults | Valid values / effect | +| --- | --- | --- | +| `[layout]` | `upper_pct = 55`, `file_list_pct = 25` | Each is `1..=99`; TUI panel proportions. | +| `[theme]` | `name = "yellow"` | `yellow`, `cyan`, `green`, `magenta`, or `blue`. Seeds the session accent when no saved accent exists. | +| `[input]` | `leader = "ctrl+f"` | One `ctrl+` chord. `ctrl+i` and `ctrl+m` are rejected because terminals report them as Tab and Enter. | +| `[mouse]` | `enabled = true` | Captures clicks and wheel events for the TUI; `false` gives selection and mouse handling back to the outer terminal. | +| `[agent_indicator]` | `enabled = true`, `hot_window_secs = 15`, `auto_follow = false` | Hot window is `3..=3600` seconds. `auto_follow` selects the freshest recently changed file after 2 seconds of inactivity. | +| `[tree]` | `respect_gitignore = true`, `max_depth = 64`, `live_watch = true` | `max_depth` is `1..=1024`; `live_watch = false` refreshes the tree on entry instead of watching expanded directories. | +| `[shell]` | `program` omitted; `command_args` platform default | Unix uses `$SHELL` or `/bin/sh` with `[-lc]`; Windows uses `%ComSpec%` or `cmd.exe` with `[/C]`. The command is always the final single argument; interpolation such as `"{}"` is not supported. | -```toml -[input] -leader = "ctrl+f" # leader (prefix) chord for app commands; tmux-style. - # Allowed: "ctrl+". Reserved keys (F1..F10, - # Shift+arrows, Shift+PgUp/PgDn) cannot be the leader. -``` - -## `[mouse]` - -```toml -[mouse] -enabled = true # capture the mouse: click to focus/forward, wheel scrolls - # the pane under the pointer; select text with the - # terminal's bypass modifier + drag (Shift in xterm-family, - # Option in iTerm2, Fn/Option in macOS Terminal.app). - # false = plain-drag selection, no click forwarding. -``` +The web viewer has its own panel proportions and sidebar width in `~/.nightcrow/viewer.json`; `[layout]` controls the TUI only. Shared files and ownership are described in [Session state](session-state.md). ## `[web_viewer]` +The viewer is always part of a session. Defaults are `bind = "127.0.0.1"`, `port = 8091`, and `session_ttl_hours = 24`. + ```toml [web_viewer] -bind = "127.0.0.1" # loopback only by default; plain HTTP, so tunnel/proxy for remote +bind = "127.0.0.1" port = 8091 -# password = "..." # auto-generated + saved here on first launch if unset -# hashed_password = "..." # Argon2 PHC string; takes precedence over `password` -session_ttl_hours = 24 # how long a browser login lasts (0 = never expires) +# password = "..." +# hashed_password = "$argon2id$v=19$..." +session_ttl_hours = 24 ``` -See [Web viewer → Configuration and access](web-viewer.md#configuration-and-access). +`bind` must be an IP address and `port` must be non-zero. `session_ttl_hours` accepts `0..=87600` hours; `0` means sessions do not expire on the server, while browser cookies still have a 400-day maximum. If neither credential is set, startup generates a random password, saves it to this file, and prints it once. `hashed_password` is an Argon2 PHC string and takes precedence over `password`. Login attempts are rate-limited, logout revokes the server-side token, and persisted tokens live in `~/.nightcrow/sessions`. + +The command-line options `--bind ADDRESS` and `--port PORT` override these values for one daemon run. The listener uses plain HTTP, so remote access requires an SSH tunnel or TLS reverse proxy; see [Web viewer → Access and security](web-viewer.md#access-and-security). ## `[log]` ```toml [log] enabled = true -dir = ".nightcrow/logs" # relative paths resolve under the home directory -rotation = "daily" # "daily" | "hourly" | "size" -max_size_mb = 10 # used when rotation = "size" -max_days = 7 # delete logs older than N days (0 = keep forever) -level = "info" # "error" | "warn" | "info" | "debug" | "trace" -prompt_log = false # record terminal prompt input line by line -commit_log_page_size = 100 # commits fetched per commit-log page -commit_log_prefetch_threshold = 25 # start the next-page fetch when the selection is within - # this many rows of the loaded tail (1..=page_size) +dir = ".nightcrow/logs" +rotation = "daily" +max_size_mb = 10 +max_days = 7 +level = "info" +prompt_log = false +commit_log_page_size = 100 +commit_log_prefetch_threshold = 25 ``` -## `[agent_indicator]` - -```toml -[agent_indicator] -enabled = true # color recently-touched files in the file list -hot_window_secs = 15 # seconds within which a file stays hot (3–3600) -auto_follow = false # jump selection to the freshest hot file when idle -``` - -See [Session state → Recent-activity focus indicator](session-state.md#recent-activity-focus-indicator). - -## `[tree]` - -```toml -# Read-only directory-tree navigator (enter with b). -[tree] -respect_gitignore = true # hide .gitignore-matched paths (target/, node_modules/, …) -max_depth = 64 # deepest directory level the tree will expand into (1..=1024) -live_watch = true # watch expanded dirs and refresh the tree live; set false - # to refresh only on tree entry (large trees / odd filesystems) -``` - -## `[shell]` - -The shell every terminal pane is spawned with. When the whole section is absent, the platform default is used: - -| Platform | `program` | `command_args` | -|----------|-------------------------------|----------------| -| Unix | `$SHELL` env var or `/bin/sh` | `["-lc"]` | -| Windows | `%ComSpec%` or `cmd.exe` | `["/C"]` | - -`command_args` is the flag list placed *after* the shell name. The command text is always the last single argv item, so the shell — not us — handles its quoting/word-splitting. Interpolation like `["-c", "{}"]` is not supported: that would break the contract that the shell owns quoting. - -```toml -[shell] -# program = "C:\\Program Files\\Git\\bin\\bash.exe" # optional; platform default when omitted -# command_args = ["-lc"] # optional; platform default when omitted -``` +Relative `dir` values are under the user's home/state directory. `rotation` is `daily`, `hourly`, or `size`; `max_size_mb` is `1..=10000` and is used for `size`; `max_days = 0` keeps logs forever, otherwise it is at most 3650 days. `level` is `error`, `warn`, `info`, `debug`, or `trace`. `prompt_log` records terminal prompt input line by line and is off by default. `commit_log_page_size` is `50..=500`; the prefetch threshold is `1..=page_size`. ## `[[startup_command]]` -Each entry opens its own terminal pane at launch and runs `command` immediately (via the configured shell). Up to 8 entries combined with CLI `--exec` — 8 matches the ` 3`–`9`,`0` jump keys, so every startup pane is reachable by a direct key. This caps only the startup batch; open more anytime with ` t`. With no entries, nightcrow opens a single empty shell. +Each entry opens one terminal pane per project and runs `command` through the configured shell. `name` is an optional tab label. `plugin` optionally names a declared plugin for that pane. ```toml [[startup_command]] -name = "Claude" # optional tab label; falls back to the command text -command = "claude" # required; must not be empty -plugin = "recovery" # optional; names the [[plugin]] allowed to act on this - # pane. Omitted — the default — means no plugin sees - # it unless that plugin sets watch_on_signal. +name = "Codex" +command = "codex" +plugin = "recovery" [[startup_command]] command = "cargo test --watch" ``` +Configured entries and repeated CLI `--exec COMMAND` values share an 8-pane startup limit, in config-first order. `command` cannot be empty. A project with no startup entries starts with one shell; each project may hold up to 8 panes total. + ## `[[plugin]]` -External plugin processes — see [Plugins](plugins.md). Up to 8 entries, names unique. Nothing runs unless an entry exists **and** `enabled = true` **and** either a pane opted in or `watch_on_signal` is set. +Each plugin entry requires a unique `name` and executable `command`; `args` and `[plugin.env]` are optional and apply to the plugin process only. ```toml [[plugin]] -name = "recovery" # the name panes opt in with -command = "nightcrow-recovery" # found on PATH or in ~/.nightcrow/plugins -args = [] # passed to the plugin verbatim -enabled = false # off by default -watch_on_signal = false # off by default; when true, a pane no - # [[startup_command]] named is also handed over - # once something inside it quotes that pane's - # token to this plugin. Such a pane is never - # relaunched, only typed into while it lives. -allowed_resume_flags = [] # flags/subcommands the plugin may append to - # re-open a session; empty refuses arguments - -[plugin.env] # plugin process only, never terminal panes -NIGHTCROW_RECOVERY_LOG = "info" +name = "recovery" +command = "nightcrow-recovery" +args = [] +enabled = false +watch_on_signal = false +allowed_resume_flags = [] + +[plugin.env] +PLUGIN_LOG = "info" ``` -## Reloading the config - -Editing `config.toml` normally means restarting the session — which kills every pane, including whatever an agent CLI was in the middle of. Two of the tables can be re-read instead, without stopping anything: +Plugins are off unless `enabled = true`. A plugin normally receives events only from panes whose `[[startup_command]]` sets `plugin =` to its name. `watch_on_signal = true` also permits a process inside an otherwise unconfigured pane to opt in with its pane token; such a pane can be monitored and receive input but cannot be relaunched. `allowed_resume_flags` is an explicit allowlist for flags/subcommands a plugin may append when relaunching a configured pane; leave it empty to forbid relaunch arguments. At most 8 plugin entries are allowed. -- **In the TUI**: ` u`. The result appears on the notice row. -- **In the browser**: the ⟳ button in the header, next to sign out. It reloads the *config*, not the page — nothing on screen changes, and the result comes back as a toast. +See [Plugins](plugins.md) for installation and the bundled recovery plugin. -| Table | When it takes effect | -| --- | --- | -| `[[plugin]]` | **Immediately, in every open project.** Newly enabled plugins start and are handed the panes that opted into them; disabled or removed ones stop and their panes carry on unwatched. A plugin whose `command`, `args` or `env` changed gets a new process; changing only `allowed_resume_flags` or `watch_on_signal` leaves the running one alone, so a plugin part-way through a long wait is not disturbed. A replacement that will not start (a command that is not there) leaves its panes unwatched too, exactly as removing it would | -| `[[startup_command]]` | **On the next project you open.** A project that is already open keeps the panes it started with — those are live processes, and no file edit replaces them | -| Everything else | Needs a restart: `[web_viewer]` (the listener is already bound), `[log]`, and the client-owned `[layout]`, `[input]`, `[tree]`, `[mouse]` sections, which each TUI reads when it attaches | +## Reloading -Notes: +Use ` u` in the TUI or the reload control in the browser. nightcrow parses and validates the whole file before applying anything; a missing, malformed, or invalid file leaves the running session unchanged. -- **Nothing half-applies.** The whole file is parsed and validated first, so a typo anywhere leaves the session exactly as it was, and the message names the key that was wrong. -- **A missing file is refused** rather than read as "nothing is configured" — otherwise deleting the file and reloading would be a quiet way to stop every plugin. -- Panes opened with `--exec` are kept: they are not in the file, so a reload merges them back where a restart would have put them. -- Disabling a plugin and enabling it again lands where enabling it the first time would — the pane's opt-in survives, so `enabled` means the same thing whichever way it was last flipped. -- **Restarting a plugin discards whatever it was in the middle of.** A plugin's state lives in its process, so replacing that process loses it — for `nightcrow-recovery` a pane parked on a quota reset hours away simply stops being watched, and nothing will resume it. The plugin logs how many panes it abandoned on the way out. This only happens when you change *that plugin's* own `command`, `args` or `env`; every other edit leaves a waiting one running. -- A pane whose process had already exited and whose slot was being held for a relaunch gives that slot up when its plugin is stopped or replaced. The successor is never handed the pane's token, so nothing could honour the hold; the countdown ends instead of running out its window. -- If the result says **`(1 was too busy to be told)`**, that project kept the plugins it had. Its terminals were too far behind to take the request, and waiting on one project would have held up every other. Nothing else about the reload is affected — reload again once it has caught up. The server log names the project. +- `[[plugin]]` is re-applied immediately to open projects. Changing a plugin's executable, arguments, or environment restarts that plugin and can abandon a pending recovery. +- `[[startup_command]]` applies to projects opened after the reload. Existing project panes keep running; CLI `--exec` panes remain part of the merged startup list. +- All other settings require a daemon restart. A TUI reads its client settings when it attaches, while the running daemon keeps its listener and server settings until restart. -Design notes: [Architecture → Session](architecture/session.md#config-reload-webviewerreloadrs). +Restarting a session stops its terminal programs. Use [Getting started](getting-started.md#detach-and-stop) for the shutdown procedure. diff --git a/docs/decisions.md b/docs/decisions.md index 01bcdfef..26bedb3c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,159 +1,87 @@ -# 설계 결정 이력 +# 설계 결정 -확정된 설계는 [`architecture.md`](architecture.md)에, 사용법은 [`../README.md`](../README.md)과 `docs/`의 사용자 문서에 있다. 이 문서는 **왜 그렇게 갔는지** — 특히 계획과 갈린 지점과 검토 후 접은 대안 — 만 남긴다. +현재 설계의 선택 이유와 중요한 대안만 기록한다. 현재 동작과 불변식은 [`architecture.md`](architecture.md)와 하위 설계 문서를 기준으로 하며, 사용자 절차는 [`README.md`](../README.md)를 따른다. -## 세션 데몬 (2026) +## 세션 경계 -### 왜 클라이언트 렌더인가 +### 데몬은 상태를 소유하고 클라이언트가 렌더한다 -가능한 구조가 둘이었다. 서버 렌더(데몬이 ratatui로 한 장 그려 ANSI를 민다)는 클라이언트가 수백 줄로 끝나지만 **그리드가 하나뿐이라 디스플레이별 크기가 불가능**하다. 모두가 같은 크기를 강제로 공유하게 된다. "디스플레이 종류·사이즈에 따라 재렌더링"이 목표에 있었으므로 성립하지 않는다. +데몬이 ratatui 화면 하나를 만들어 반사하는 방식은 터미널과 브라우저가 서로 다른 크기로 같은 pane을 볼 수 없게 한다. 데몬은 repository·PTY·공유 preference를 소유하고, 각 클라이언트가 자신의 기하와 emulator로 렌더한다. 이 선택으로 TUI와 viewer가 같은 session operation을 사용하고, 별도의 화면 반사 계층은 필요하지 않다. -그리고 클라이언트 렌더 프로토콜은 이미 있었다 — 웹 뷰어가 쓰던 것이 그것이다. 그래서 이 작업은 새 데몬을 만드는 일이 아니라 `serve`를 세션 데몬으로 승격시키고 TUI를 그 클라이언트로 돌려세우는 일이 됐다. 같은 이유로 웹 미러는 존재 이유를 잃고 제거됐다 — 브라우저가 화면 반사 대신 네이티브 프론트엔드로 같은 세션에 붙는다. +### 동기 thread 모델과 로컬 git 읽기 -### 지금도 구속하는 제약 +외부 async runtime을 추가하지 않고 bounded thread/channel을 유지한다. git diff/file/tree/log는 같은 로컬 worktree를 읽는 client-side 경로이며, 원격 attach를 위해 git 데이터를 daemon wire로 옮기는 설계는 선택하지 않는다. 그 대신 선택 로드는 `git2::Repository`를 소유하는 worker와 generation guard로 비동기 UI를 제공한다. -- **async 런타임 무도입.** 동기 스레드 모델을 유지한다. -- **git 온디맨드 읽기는 클라이언트 로컬.** diff/file/tree/log는 UI 스레드에서 동기로 읽는다. -- 새 프로토콜을 발명하지 않고 뷰어가 쓰던 메시지 타입을 전송만 바꿔 재사용한다. -- 각 커밋이 빌드·테스트를 통과하고 **쓸 수 있는 상태**여야 한다 — 중간에 TUI를 못 쓰는 기간이 없도록 단계를 배치했다. +### 단일 인스턴스와 안전한 daemon화 -### 접은 대안: 원격 attach (= git 데이터를 프로토콜에 올리기) +stale socket에 connect하는 방식은 닫힌 Unix socket에서 생존 여부를 안정적으로 판정하지 못한다. `flock`으로 process lock을 잡고, daemon mode는 fork가 아니라 `setsid`를 포함한 re-exec로 시작한다. 이미 thread가 있는 process를 fork하지 않아 lock과 thread 상태를 자식에게 물려주지 않는다. -다른 머신의 데몬에 TUI로 붙는 것은 목표에서 뺐다. 그래서 git 데이터를 프로토콜로 옮기지 않는다. 옮기려면 `app/`의 "선택이 바뀌면 그 자리에서 동기로 읽는다"는 전제를 전부 pending 상태를 갖는 비동기 요청으로 뒤집어야 하는데(`diff_load`, `commit_log_fetch`, `tree`, `file_view_load`, `snapshot_io`), 로컬에서는 양쪽이 같은 디스크를 읽어 1초 안에 수렴하므로 사용자에게 보이는 이득이 거의 없다. 비용은 이 프로젝트에서 제일 크다. +### 공유 값과 화면별 값 -### 접은 대안: stale 소켓 connect로 단일 인스턴스 판정 +active repository와 accent는 같은 session의 사실이므로 TUI와 browser 사이에 공유한다. cursor·scroll·focus·fullscreen·검색과 화면 비율(`upper_pct`)은 display마다 의미가 달라 client-local로 둔다. viewer preference와 TUI workspace도 서로 다른 파일에 두어 한 표면이 다른 표면의 view state를 덮지 않게 한다. -계획에는 "남은 소켓에 connect해서 살아 있는 데몬인지 본다"로 적혀 있었다. **macOS에서는 리스너가 닫힌 소켓에도 connect가 성공할 수 있어** 판정이 성립하지 않았다(테스트가 세 번에 한 번꼴로 실패). `flock`은 `kill -9`에도 커널이 해제하므로 정확하다 — 그래서 `libc`를 직접 의존성으로 올렸다(std가 노출하지 않는 유일한 호출). +### PTY 크기는 latest viewer 하나가 소유한다 -### 접은 대안: 데몬화 크레이트, 그리고 fork +PTY child와 alternate-screen 프로그램은 전달받은 폭에 맞춰 화면을 만들고, 화면별 크기를 사후에 합칠 수 없다. 따라서 session에 하나의 size owner를 두고, 명시적인 arrival/claim 때만 이전한다. 입력마다 owner를 바꾸면 휴대폰의 잠깐 확인이 모든 pane repaint를 일으키므로 배제했다. 비소유 client는 관전자이며 실제 `Resized` event만 따른다. -데몬화 크레이트는 표준 없이 파편화돼 있어(daemonize/daemonize2/daemonizr/fork) 채택하지 않았다. `-d`는 **fork가 아니라 재exec + `setsid`**다. 스레드가 도는 프로세스에서 fork하면 자식은 스레드 하나와 임의 상태의 락들을 물려받는다 — 데몬화 크레이트들이 조심스러워하는 것이 정확히 그것이다. 새 사본을 exec하면 물려받을 상태가 없다. +## 상태·스트림·동시성 -### 접은 대안: 입력마다 PTY 크기 소유권 이전 +### 상태는 변화 기반으로 읽고, repository set은 한 producer가 보낸다 -PTY는 데이터가 아니라 자식 프로세스와 맺은 계약이라(자식은 `TIOCGWINSZ`로 들은 폭에 맞춰 출력을 만든다) pane의 셀 크기는 단일 값이어야 한다. 클라이언트마다 자기 크기로 에뮬레이터를 돌리는 방식은 줄 단위 출력에만 통하고, alternate screen을 쓰는 풀스크린 TUI(Claude Code, Codex, vim)에서는 두 화면이 서로 다른 쓰레기로 갈라진다 — 그게 이 앱의 주 용도다. +매초 모든 worktree를 읽는 대신 filesystem watcher를 사용하고, watcher 설치 실패 때만 1초 timer로 폴백한다. subscriber 없는 repository는 읽거나 감시하지 않는다. HTTP와 attach가 동시에 session을 바꿀 수 있으므로 set/active/accent를 다시 읽어 보내는 producer를 `daemon/watch.rs` 하나로 제한한다. callback을 각 mutation에 흩뜨리면 새 경로가 broadcast를 빠뜨릴 수 있고, 두 producer는 frame 순서를 뒤집을 수 있다. -그래서 tmux의 `window-size latest` 모델을 택했다. 여기서 **입력마다 소유권을 옮기는 대안은 기각**했다. 폰으로 잠깐 확인하는 흔한 동작이 곧바로 전체 repaint를 유발해, 제일 가벼운 행동이 제일 비싼 행동이 된다. 부수 효과로 비소유 클라이언트가 곧 관전자가 되므로 별도의 관전 모드를 만들 필요가 없어졌다. +### 완전한 snapshot은 합치고 terminal byte는 보존한다 -### 뒤집힌 결정: accent +status는 최신 값 하나가 완전한 그림이라 중간 값을 conflate할 수 있다. terminal output은 escape sequence와 multibyte stream이므로 한 byte도 생략할 수 없다. 그래서 terminal queue는 bounded FIFO이고 overflow client는 끊어 일관된 replay를 다시 받게 한다. replay는 screen snapshot과 그 이후 `since`를 결합하고, daemon frame 상한을 넘지 않도록 1 MiB chunk로 보낸다. -초안은 accent를 공유로 뒀다가 **클라이언트별로 뺐다** — 세션 사실이 아니라 표시 취향이고, TUI의 accent는 저장소별이라(색으로 탭을 구별하는 것이 그 기능의 목적) 세션 전역 값 하나로 만들면 그것이 사라진다는 이유였다. +### Catalog membership과 runtime은 분리한다 -**그 뒤 다시 뒤집혔다.** 한 세션을 TUI와 브라우저로 나란히 두면 같은 세션이 두 색으로 보였고, 어느 쪽이 세션의 색이냐에 답할 수 있는 값이 없었다. 지금 accent는 세션 전역 값 하나다. 저장소별 색이 대신하던 "지금 어느 프로젝트인가"는 탭 이름과 활성 탭 강조가 답한다. 현재 경계는 [`architecture.md`](architecture.md)의 "세션 공유" 절이 기준이다. +순수 membership 계산과 worker/hub 수명을 한 객체로 섞으면 tab reorder나 config reload가 무관한 subscriber를 재생성한다. path를 기준으로 runtime entry를 보존하고, membership/runtime/config table 변경을 하나의 transaction으로 직렬화한다. retired worker의 join은 transaction lock을 놓은 뒤 실행해 한 repository의 종료 지연이 다음 mutation을 막지 않게 한다. -활성 프로젝트도 D단계에서는 클라이언트별로 구현돼 있었고 뷰어 코드에도 같은 판단이 박혀 있었지만(`resolveActiveRepo`) 공유로 확정했다. 대가는 알고 택했다 — 폰에서 탭을 바꾸면 노트북 화면도 옮겨간다. 대신 "브라우저와 TUI가 서로 다른 프로젝트를 보여주는데 둘 다 정상"인 상태가 없어진다. +### Reload는 전체 검증 후 제한적으로 적용한다 -### 계획과 갈린 지점 +살아 있는 pane을 보존하려고 `config.toml`을 부분 적용하지 않는다. 파일 전체를 parse/validate한 뒤 `[[plugin]]`은 열린 hub에, `[[startup_command]]`는 새 hub에만 적용한다. plugin 권한 flag와 watch switch는 다음 판정부터 읽고, child 교체가 필요한 command/args/env만 재시작한다. concurrent reload는 lock으로 직렬화하고 전달하지 못한 hub는 성공으로 가장하지 않는다. -- **B단계(빈 `attach` 별칭 + `application/` → `client/` 이동)는 C·D에 흡수했다.** 아직 클라이언트가 아닌 것에 클라이언트 이름을 붙이는 선반영이라, 실제 소비자가 생기는 시점에 만들었다. -- **순서를 D → F → E로 바꿨다.** F를 E 뒤에 두면 E 내내 `TerminalState`가 로컬 PTY와 원격 hub 양쪽을 다뤄야 한다. F를 먼저 하면 E는 최종 형태 하나만 본다. 대가는 E가 끝날 때까지 detach가 터미널을 죽인다는 것인데, 그건 지금도 없는 기능이라 잃는 것이 아니라 아직 얻지 못한 것이었다. -- **`--repo`는 남기지 않고 지웠다.** 같은 이름이 자리마다 다른 뜻이었다 — TUI에서는 기억된 목록을 *대체*하고, 데몬과 attach에서는 *추가*했다. 여러 클라이언트가 공유하는 세션에서 저장소를 여는 자리는 안쪽 하나뿐이라 둘 중 하나로 통일하는 대신 없앴다. 밖에서 세션을 미리 채우는 요구가 생기면 argv가 아니라 config에 둔다. -- **알림(callback)이 아니라 관측.** 데몬은 틱마다 세션을 다시 읽어 마지막으로 알린 것과 다르면 브로드캐스트한다. 알림 방식은 나중에 추가된 mutation이 빼먹을 수 있고, 그 실패가 정확히 이 버그로 다시 나타난다. +## TUI 입력과 git 표시 -### G단계: 측정하고 다르게 고쳤다 +### 앱 명령은 leader 뒤에 둔다 -계획은 "TUI가 데몬 스냅샷을 구독"(중복 제거)이었다. 먼저 쟀다 — `git status` 한 번은 파일 260개에서 3 ms, 1만 개에서 23 ms, 5만 개에서 129 ms. 폴링 수는 클라이언트 수에 비례하므로 5만 파일 트리 + 4탭 TUI 하나면 초당 516 ms인데 그중 중복은 129 ms뿐이었다. **주범은 중복이 아니라 "안 바뀌었는데도 매초 걷는 것"이었다.** +LLM CLI와 shell의 `Ctrl+W`, `Ctrl+L` 같은 입력을 보존하려면 일반 key를 전역 단축키로 예약할 수 없다. 기본 `Ctrl+F` leader 뒤에 앱 명령을 두고, F-key와 shift-only navigation만 예외적인 no-prefix 예약키로 둔다. leader timeout은 두지 않아 사용자가 중첩 TUI의 prompt 입력을 잃지 않는다. -그래서 (1) 구독자 없는 저장소는 걷지도 감시하지도 않고, (2) 읽기를 변화 구동으로 바꿨다. 유휴 비용이 초당 129 ms → 13 ms로 떨어졌고 변화 감지는 최대 1초 → 즉시가 됐다. +### chrome 행과 git status 표기는 단일 규칙으로 유지한다 -**안 한 것**: 데몬 스냅샷 구독. 남는 중복은 유휴에서 10초에 한 번뿐이고, 닫으려면 주기적 git 데이터를 attach 프로토콜에 올려(위에서 피한 그것) TUI가 자기 뷰를 만드는 두 번째 경로를 갖게 된다 — 가장 많이 쓰는 화면에 단일 실패점을 만드는 값이다. +notice/hint가 나타날 때마다 행을 삽입하면 모든 PTY를 resize하고 프로그램을 다시 그리게 한다. 그래서 project tabs, body, notice, hint 네 행을 항상 만들고 notice를 overlay한다. status는 새로운 표기보다 익숙한 `XY path`를 택하고, staged/worktree 두 열은 같은 `StatusKind`로 모델링한다. rename의 유효 경로와 표시 경로를 분리하고 typechange/conflict를 modified로 숨기지 않는다. -### 넣지 않은 것 +### repo picker는 셸이 아닌 네이티브 경로 탐색이다 -- **재연결.** 연결이 끊기면 TUI가 alternate screen을 정상적으로 벗고 이유 한 줄과 함께 비정상 종료한다. 메시지는 "세션이 사라졌다"가 아니다 — 데몬이 살아 있는데 이 연결만 끊긴 경우(뒤처진 클라이언트를 데몬이 끊는 경로)가 있으므로 확실한 것만 말하고 재attach를 권한다. 넣으려면 연결을 재접속 가능한 채널로 바꾸고 재접속마다 pane을 버리고 리플레이를 다시 받아야 한다(그러지 않으면 스크롤백이 에뮬레이터에 두 번 들어간다). 지금 설계를 막지 않으므로 필요해지면 그때 올린다. -- **named session.** 사용자당 데몬 하나를 전제한다. -- **스크롤백 상한 변경.** 실측 결과 평범한 출력은 클라이언트의 1000줄을 다 채우고, 줄당 ~262바이트를 넘는 escape-heavy 출력만 그보다 얕다. 경계 양쪽을 테스트로 고정하고 (`terminal/tests/scrollback_depth.rs`) 상한은 그대로 뒀다. +readline을 PTY로 띄우는 방식은 Windows 대응이 없고, PTY stream에서 후보와 결과를 다시 구분해야 한다. `std::fs::read_dir` 기반 picker는 OS별 shell 의존성과 추가 protocol 없이 세 플랫폼에서 같은 입력 모델을 제공한다. 입력한 `~`/relative spelling은 화면에 보존하고 읽는 순간에만 확장한다. -## 웹 뷰어 +## Web surface -### 계획과 갈린 지점 +### viewer는 TUI mirror가 아닌 두 번째 frontend다 -- **shadcn/ui 미채택.** 계획 자체가 "기본 톤을 TUI 밀도로 재조정해야 한다"고 적고 있었는데, 실제 UI가 커스텀 고밀도 패널이라 덮어쓸 것이 쌓을 것보다 많았다. Tailwind가 토큰을 직접 든다. -- **"연결 수명" 단계는 미러를 건드리지 않았다.** `SseStream`이 자기 헤드를 쓰고 소켓을 소유하는 구조로 해소돼, 소비자 없는 상태에서 미러 응답 경로를 고칠 이유가 없었다. -- **경로 검증 위치가 바뀌었다 — 이게 실제 버그였다.** 계획은 "tree/file/commit 전 엔드포인트가 검증기를 공유"라고만 적었는데, 그것을 **라우트별로 구현하면 새 라우트가 빠뜨린다.** 실제로 `/api/diff`가 `../../etc/passwd`를 받아들였다 — `load_file_diff`는 경로를 파일이 아니라 git pathspec으로 쓰므로 검증기에 닿지 않고, 빈 hunk와 함께 공격자 경로를 되돌려줬다. 검증은 dispatch 한 지점(`with_repo`)에 있어야 "어떤 로더를 부르느냐"와 무관하게 안전하다. 이 규칙은 지금도 구속한다: **새 repo 라우트는 자기 경로 검증을 하지 않는다.** +TUI grid를 이미지처럼 반사하면 browser geometry와 responsive layout을 지원하기 어렵다. viewer는 session의 git/runtime/terminal primitive만 공유하고 자체 JSON/SSE/WebSocket/React surface를 갖는다. 인자 없이 시작한 daemon이 viewer를 함께 띄우며, `viewer-ui/dist`를 함께 배포해 Node 없는 `cargo install`도 실행 가능하게 한다. -### 왜 미러가 아니라 별도 서비스인가 +### 요청 순서와 path gate는 중앙에서 고정한다 -미러는 TUI 그리드를 그대로 반사해 `App`+`ui`+`input`을 통째로 재사용했다. 뷰어는 그 계층을 하나도 쓰지 않고 하부 데이터/PTY 계층만 공유하는 두 번째 프론트엔드다. 그래서 미러의 "무빌드·바닐라·`include_str!`" 제약을 상속하지 않는다 — 별도 서비스엔 깰 불변식이 없으므로 React/Vite 빌드가 정상적 선택이었다. +Host를 Origin보다 먼저 보고, static bundle을 인증 전 허용하고, repository lookup보다 authentication을 먼저 수행한다. route별 path 검증은 새 route가 빠뜨리기 쉬우므로 파일을 여는 `with_repo`와 git에 넘기는 `with_repo_git_path` 두 중앙 gate로 제한한다. 삭제된 file diff까지 막는 과도한 filesystem check는 허용하지 않는다. -같은 이유로 터미널을 **독립 세션**으로 뒀다(당시 기준). TUI와 같은 세션 터미널은 PTY가 `App`에 있어야 해서 "서버는 App을 참조하지 않는다"는 전제와 헤드리스 모드가 깨졌다. 이 판단은 이후 세션 데몬이 PTY 소유권을 데몬으로 올리면서 자연스럽게 해소됐다. +### opaque repository id와 anchor pagination -### 접은 대안 (그 밖) +클라이언트에 absolute path를 주지 않고 process 수명 동안 안정적인 opaque id만 사용한다. `/api/log`는 마지막 commit을 cursor로 사용하지 않는다. merge history에서 cursor의 조상만 걷게 되면 병렬 branch commit이 누락되므로, 같은 revwalk의 `from` anchor와 `skip`을 사용한다. -- **스레드 로컬 `Repository` 캐시.** 서버가 연결마다 스레드를 새로 뜨고 요청 처리 후 종료하므로 캐시가 그 스레드와 함께 버려져 이득이 없다. -- **Vite `dist/`를 커밋하지 않기.** `cargo install nightcrow`이 Node 없이 동작해야 한다. `build.rs`에서 npm을 부르면 crates.io 설치 사용자가 깨지고, cargo feature로 가르면 CI 매트릭스와 조건부 컴파일이 는다. 비용인 산출물 diff 노이즈는 `.gitattributes`의 `linguist-generated`로 완화한다. -- **미러의 팬아웃(`Shared`/`ClientMsg`/`Buffer`)을 `web/common`으로 올리기.** 그 팬아웃은 터미널·그리드 전용이라 뷰어의 JSON/SSE/터미널과 일반화되지 않는다. 공유는 안정적 프리미티브만(auth, 세션 저장, rate-limit, 요청/응답 파싱). -- **커서 기반 log 페이징.** 마지막 커밋 oid에서 다시 walk하면 병합 히스토리에서 그 커밋의 *조상만* 나오므로, HEAD 기준 날짜순 walk에 끼어 있던 병렬 브랜치 커밋이 영구히 누락된다. anchor(`from`) + `skip`은 같은 walk의 offset이라 그 문제가 없다. 현재 동작과 알려진 대가는 [`architecture.md`](architecture.md)의 Web Viewer 절에 있다. -- **TUI의 split-view/fullscreen/swap/visible-window 로직 재사용.** 웹은 터미널 탭/기본 그리드의 자체 단순 모델로 갔다. +### clone은 git subprocess와 URL allowlist다 -## repo 다이얼로그 경로 탐색 +libgit2 vendored build는 SSH transport·credential helper·scp-like remote 지원이 부족하므로 clone은 `git` binary에 위임한다. `ext::`가 command execution으로 이어질 수 있어 URL scheme을 `https/http/ssh/git+ssh`와 scp-like 형태로 제한하고 `file://`, local path, `git://`는 거부한다. destination은 먼저 `create_dir`로 확보하고, clone job과 동시 실행 수는 bounded하게 유지한다. -### 접은 대안: 셸을 PTY로 띄우기 +## Plugin trust model -`bash --norc -c 'read -e -p ...'`(readline)이나 zsh `vared`를 PTY로 띄우면 완성이 공짜로 따라온다. 접은 이유: +### dylib 대신 process + NDJSON -- **Windows에 대응 프리미티브가 없다.** PowerShell `Read-Host`는 완성이 없고(PSReadLine은 대화형 호스트 루프 전용), cmd `set /p`도 없다. Windows를 목표로 두는 순간 네이티브 완성기를 어차피 써야 하므로 셸은 *대체*가 아니라 *추가* 경로가 된다. -- 결과 회수가 PTY 스트림 하나뿐이라 sentinel/임시 파일 프로토콜이 필요하다. -- readline 후보 목록은 여러 줄 + "Display all N possibilities?"를 뿜어 hint bar 1줄에 안 들어가고, PTY 그리드 렌더 영역을 새로 만들어야 한다. -- `$SHELL`을 그대로 쓰면 rc 오염·시작 지연·rc가 입력 대기 시 먹통 리스크가 붙는다. +Rust에는 안정적인 plugin ABI가 없어 dylib가 compiler/runtime 결합과 주소 공간의 안전성 문제를 만든다. plugin을 child process로 분리하고 versioned NDJSON으로 통신하면 host가 line/payload bound를 적용하고 plugin crash를 pane에 전파하지 않을 수 있다. -`std::fs::read_dir` 기반 네이티브 구현은 새 의존성 0, `cfg(windows)` 분기 0으로 같은 체감을 준다. +### pane opt-in은 token 증명과 guard를 거친다 -### 접은 대안: 기존 트리 인프라 재사용 +기본적으로 startup command가 지목한 pane만 plugin에 노출한다. `watch_on_signal`을 켠 경우에도 pane child에만 주입된 난수 `PaneToken`을 제시해야 하며, token만으로 권한을 부여하지 않고 `Guard`가 generation·liveness·launch command·다른 watcher·rate budget을 다시 판정한다. relaunch budget은 새 PaneId가 생겨도 같은 slot을 묶도록 token 기준으로 센다. -`ViewMode::Tree` 자산을 쓰고 싶었지만 대부분 못 썼다. `git::tree::read_children`은 `git2::Repository`가 필수이고 **repo-relative** 경로만 받으며 `resolve_in_workdir`이 워크트리 밖 경로와 심볼릭 링크를 거부한다 — 피커는 *어떤 repo에도 속하지 않는* 경로를 돌아다녀야 하고 **프로젝트 0개 상태**에서도 떠야 한다. 그 함수가 막으려고 만들어진 것이 정확히 피커의 일이다. `TreeView`는 `App` 소유(프로젝트별)이고 search index / show_set / row_width_cache가 repo-relative 전용이며, `tree_list::render`는 `&App`·`app.focus`에 의존해 빈 화면에서 호출조차 안 된다. 실제 재사용은 `render_selectable_list` 하나였다. - -### 계획과 갈린 지점 - -- **진입 키는 `Ctrl+T`가 아니라 `↓`다.** `T` 니모닉이 ` t`(새 터미널)와 겹쳐 "충돌하지 않는다"를 설명해야 했는데, 설명이 필요한 키는 이미 진 것이다. 다이얼로그의 다른 키가 전부 bare인 것과도 맞고, 필드의 수평 키가 이미 "이 경로를 편집한다"는 뜻이라 수직 축이 비어 있었다. 후보 목록이 떠 있을 때의 두 번째 `Tab`도 같은 곳으로 승격한다. -- **hint 행에 키 legend를 붙였다** (계획에 없던 항목). 다이얼로그가 hint legend를 통째로 입력 줄로 대체해서 `Tab` 완성조차 화면에 안 나오고 있었다. 진입 키를 아무리 잘 골라도 광고할 자리가 없으면 못 찾는다. (이후 입력이 notice 행의 repo 헤더 자리로 올라가면서 legend가 hint 행을 통째로 갖게 됐다 — [architecture/ui.md](architecture/ui.md)의 저장소 열기 다이얼로그 절.) -- **상태는 `BTreeSet` + children 캐시가 아니라 평면 row 리스트다.** 확장이 자식을 부모 뒤에 splice하고 접기가 아래 깊은 row를 drain하면 선택이 화면 인덱스 그대로여서 visible_rows 계산도 캐시 무효화도 필요 없다. 계획이 트리 뷰의 구조를 따라가려 했지만 그쪽 복잡도는 repo-relative 검색 인덱스에서 온 것이고 브라우저에는 없다. -- **플로팅 팝업이 아니라 body 영역 전체.** `src/ui/`에 팝업/오버레이 인프라가 전혀 없어서 (`Clear` 위젯도 centered-rect 헬퍼도 없다) 떠 있는 박스는 이 프로젝트 최초의 플로팅 UI가 되고 마우스 캡처가 기본 on이라 `hit_test.rs`에 새 히트 영역을 끼워야 한다. -- **마우스 클릭 선택은 계획대로 범위 밖.** 키보드로 완결된다. - -### 유지되는 원칙 - -사용자가 입력한 텍스트는 다시 쓰지 않는다 — `~`나 상대 경로는 **읽을 때만** 확장하고 버퍼에는 완성된 컴포넌트만 이어붙인다. `~/x`를 `/Users/me/x`로 바꿔 써넣지 않는다. 셸이 아니므로 커맨드·`$VAR`·글롭·커맨드 치환은 없고 Enter는 항상 "이 경로 열기"다. - -## git status XY 표기 - -### 왜 git 표기를 그대로 쓰는가 - -기존 표시는 collapse된 한 글자여서 "무엇이 바뀌었는가"는 보여도 "그 변경이 어디에 있는가"(staged / unstaged / 둘 다)를 못 보여줬다. **새 멘탈 모델을 만드는 대신** 사용자가 `git status --short`에서 이미 아는 `XY path` 관례를 그대로 가져왔다. git2가 깔끔히 표현하지 못하는 경우가 아니면 자체 표기를 만들지 않는다는 것이 이 작업의 non-goal이었다. - -### 접은 대안: `git status --short` 파싱 - -셸아웃하지 않는다. 스냅샷 워커가 느려지고 테스트가 어려워지며, git2가 이미 주는 정보를 중복한다. - -### 결정: 두 칸이지만 enum은 하나 - -`ChangedFile`이 `index`/`worktree` 두 컬럼을 갖되 **같은 `StatusKind` 하나**를 쓴다. 커밋 drill-down은 `worktree = Unmodified`로 같은 타입을 재사용하므로 status 리스트와 커밋 리스트에서 status의 의미가 하나다. 이름을 `ChangeStatus` → `StatusKind`로 바꾼 이유도 같다 — 이제 이 엔티티는 "파일의 변경 종류"가 아니라 **단일 diff 컬럼의 상태**를 모델링하고, 그 컬럼은 `Unmodified`일 수 있는데 옛 이름으로는 표현되지 않았다. - -### 결정: 색은 두 글자 한 덩어리에 최고 심각도 하나 - -`unmerged > deleted > renamed > added > modified > typechanged > untracked` 순으로 더 심각한 쪽 색을 두 글자 전체에 칠한다. 기존 단색 행 모양과 `status_color` 시그니처를 그대로 유지하기 위해서다. - -### 결정: 충돌 행은 첫 판에 `UU` 고정 - -`AA`/`DD`/`AU`/`UD`/`DU`를 나중에 데이터 구조를 다시 만들지 않고 넣을 수 있도록, 구조화된 컬럼은 유지한 채 렌더만 `UU`로 고정했다. **조용히 modified로 뭉개지 않는다**는 것이 요점이다. - -### 결정: rename은 `path`와 표시를 분리 - -`path`는 diff·파일 로드·hot-file 추적·선택 복원이 쓰는 유효(new-side) 경로로 남고, `old_path`는 표시/검색 메타다. `display_path()`가 `old -> new`를 만들되 **비-rename에서는 `Cow::Borrowed`로 할당 없이** 돌려준다 — 리스트 렌더는 매 프레임 돌고 그쪽이 hot case다. `impl Display`가 아니라 `Cow`인 이유는 렌더러가 수평 스크롤 때문에 `char_offset(&str) -> &str`로 슬라이스하고 `.chars().count()`로 재기 때문이다. 검색은 `search_lower`에 양쪽 경로를 함께 담아, 필터 로직을 바꾸지 않고도 옛 경로/새 경로 어느 쪽으로도 찾힌다. - -### 유지되는 제약 - -- **정렬은 결정적이어야 한다.** 옛 `BTreeMap` "first-wins" collapse가 사라져도 안정 정렬은 남는다 — 새로고침마다 순서가 흔들리면 선택이 튄다. -- **typechange를 modified로 뭉개지 않는다.** `load_snapshot`(status 비트)과 `load_commit_files`(`git2::Delta::Typechange`) 양쪽에서 `T`로 보존한다. -- 생산 코드에 대칭성만을 위한 헬퍼를 추가하지 않는다. 테스트에만 쓸 것은 `#[cfg(test)]` 아래 두고, 실제 호출처가 생길 때 만든다. - -### 미룬 것 - -같은 파일의 staged/unstaged diff를 따로 보여주는 것. 현재는 HEAD→workdir(인덱스 포함) 결합 diff(`load_file_diff`)가 기본이고, 이 로더는 경로로만 키잉되어 status를 읽지 않으므로 모델 변경의 영향을 받지 않는다. stage/unstage 액션이 들어올 때 다시 본다. - -## 그 밖 - -- **플러그인은 dylib가 아니라 자식 프로세스 + NDJSON이다.** Rust에는 안정 ABI가 없어 `libloading` 기반 dylib 플러그인은 컴파일러 버전이 맞아야만 동작한다. 현재 설계는 [`architecture.md`](architecture.md)의 Plugin Host 절에 있다. -- **`git://`는 클론 URL 화이트리스트에서 뺐다.** 인증도 암호화도 없어 경로 위의 누구든 임의 코드를 클론시킬 수 있고, git이 stall 제어를 주지 않는 유일한 전송이라 죽은 원격이 클론 슬롯을 재시작까지 쥔다. `https://`가 같은 익명 fetch를 두 문제 없이 대신한다. -- **호스트 터미널 커서 색을 OSC 12로 강제하지 않는다.** ratatui는 ANSI 코드로 렌더하고 호스트 팔레트가 그리는데, 별도 hex를 밀어 넣으면 어두운 ANSI green을 쓰는 터미널에서 커서만 밝은 라임으로 튄다. 관련 코드는 전부 제거했고 호스트 기본 커서 색을 그대로 쓴다. -- **`is_empty_head`의 문자열 매칭은 의도적이다.** 빈 repo에서 libgit2가 `class=Reference + GenericError` 조합으로 응답해 ErrorCode 매칭만으로는 커버되지 않는다. libgit2 내부 메시지는 로케일 독립이라 문자열 매칭이 portable하다. +← [Architecture index](architecture.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index f901a4a8..61431d4c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -2,119 +2,90 @@ ## Install -Install straight from the repository (the built viewer bundle is committed, so this needs no Node toolchain): +Install the released source directly from GitHub: ```bash cargo install --git https://github.com/code0xff/nightcrow --locked ``` -Or build a local checkout: +For a checkout you are developing locally: ```bash cargo install --path . --locked ``` -Once published to crates.io this will also work: +Rust 1.85 or newer is required. `--locked` uses the repository's committed `Cargo.lock`. The browser bundle is committed and embedded in the binary, so these commands do not require Node.js. -```bash -cargo install nightcrow --locked -``` - -Requires Rust 1.85+ (edition 2024) on macOS, Linux, or Windows. `--locked` builds against the committed `Cargo.lock` for a reproducible install. - -## Updating - -```bash -nightcrow update -``` - -This reinstalls from the upstream repository. `--path ` installs from a local checkout instead, and `--git ` from a different repository. It runs `cargo install` underneath, so it needs the same Rust toolchain the first install did. - -Prefer this over rerunning `cargo install` by hand, because on Windows the plain install fails while a session is running: - -``` -error: failed to move `...\nightcrow.exe` to `...\nightcrow.exe` -Caused by: Access is denied. (os error 5) -``` - -Windows holds a lock on the file behind every running process, so the installer cannot write over it — unlike macOS and Linux, where overwriting a running binary is allowed and the plain `cargo install` works as-is. `update` sidesteps the lock by renaming the installed binary out of the way first, which Windows *does* permit, leaving the install path free. If the old binary is still in use it is left beside the new one and removed the next time nightcrow starts. - -A running session keeps the version it started with. Restart it to pick up the new one: +Create a commented configuration starter when needed: ```bash -nightcrow stop -nightcrow attach +nightcrow init ``` -## Running a session +An existing `~/.nightcrow/config.toml` is preserved; use `nightcrow init --force` only when you intend to replace it. See [Configuration](configuration.md) for the fields and defaults. -nightcrow runs as a **session**: one process holds the repositories and the terminals, and you reach it from a terminal (`nightcrow attach`) or a browser. Closing a client leaves the session running. +## Run a session ```bash -# The usual way in: attach the TUI, starting a backgrounded session first if -# none is running. An already-running session is attached to as-is, not -# duplicated. +# Start in the background if needed, then attach the TUI. nightcrow attach -# Start the session. Runs in the foreground until you stop it (Ctrl-C). -# It reopens the repositories from last time — nothing, on a first run. +# Start in the foreground (Ctrl-C stops it). nightcrow -# ...or run it in the background and get your shell back. +# Start in the background and return to the shell. nightcrow -d - -# From another terminal: bring up the TUI on that session (same command). -nightcrow attach - -# Launch terminal panes running commands at startup (repeatable) -nightcrow --exec "claude" --exec "codex" ``` -The session prints the address of its browser view (`http://127.0.0.1:8091/` by default) and the socket an attaching terminal uses. Both show the same repositories; open one with ` o` in the TUI or the folder picker in the browser, and it appears in the other. There is no flag for opening a repository — a session several clients share has one sensible place to do it, and that is inside. - -## Detaching, disconnects, and shutdown +There is one session per running daemon. `nightcrow attach` reuses an existing session and starts one in the background when none is available. The daemon owns the open repositories and terminal programs; clients only attach to it. Startup prints the browser URL and the attach-socket path. A background daemon writes its output to `~/.nightcrow/daemon.out`. -Leaving the TUI (` q`) detaches: the session, and everything running in its terminals, keeps going. Stopping the session is stopping the process you started it in — or `kill`ing it, if you used `-d`, in which case its output is in `~/.nightcrow/daemon.out`. Under a service manager, start it *without* `-d`: backgrounding is what the manager does itself. +Use `--exec COMMAND` once per startup pane when starting a daemon. Configured `[[startup_command]]` entries run first, followed by these CLI commands. The combined startup list is limited to 8 panes per project; each project gets its own list. With no startup commands, a project starts with one shell. All terminal panes in a project share an 8-pane limit; later panes are opened with ` t` until that limit is reached. -If the connection to the session ends under it — the session was stopped, or it dropped a client that fell too far behind — the TUI leaves and says so, with a non-zero status. What it had selected and scrolled is written back either way, so reattaching returns to it. There is no automatic reconnect: reattach when the session is up. +The browser and TUI share repositories, terminals, project order, active project, and accent. The TUI's leader is `Ctrl+F` by default; see [Keyboard and mouse](keybindings.md) for all controls. -## Startup panes +## Detach and stop -Startup panes belong to a project, not to the process: each project you open gets its own set. So `nightcrow --exec claude` with no repositories starts `claude` in the first project opened, not before there is one to open it in. +` q` leaves the TUI while the session and its panes continue running. Reattach with `nightcrow attach`. Stop the daemon and its terminal programs with: -`--exec` panes open after any `[[startup_command]]` panes from the [config file](configuration.md#startup_command); the two sources share a combined cap of 8 panes — the same count the ` 3`–`9`,`0` jump keys address, so every startup pane is reachable by a direct key. (` 1`/`2` map to the file list and diff viewer.) Panes opened later with ` t` are not capped; any past the eighth are reached by focus cycling (`Shift+←/→`). - -## Where to go next +```bash +nightcrow stop +``` -- [Projects](projects.md) — opening repositories as tabs -- [Views](views.md) — what each panel shows -- [Keyboard and mouse](keybindings.md) — the full binding reference -- [Configuration](configuration.md) — `nightcrow init` and every setting +If a client loses its connection, reattach after confirming that the daemon is still running; there is no automatic reconnect. `nightcrow stop --socket PATH` targets a non-default daemon socket. -## Building and testing +## Update -### Prerequisites +```bash +nightcrow update +``` -Requires Rust 1.85+ (edition 2024). The viewer bundle is committed, so a plain build needs no Node toolchain. Building the viewer from source needs Node 22 — see `viewer-ui/`. +By default this reinstalls from the upstream repository. Use `--path DIR` for a local checkout or `--git URL` for another Git repository. The command requires Rust and runs 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; rerunning plain `cargo install` while a session is running can fail because Windows locks the executable. -### The four gates +## Building and testing -`cargo build`, `cargo test`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo fmt --all --check` must all pass. The pre-push hook (`git config core.hooksPath .githooks`) runs the same gates CI does, scoped to what changed. +The Rust verification gates are: -Changing anything under `viewer-ui/src` adds two more, run before the Rust ones because they fail faster: `npm --prefix viewer-ui test` covers the `src/lib` helpers and the React hooks (a test that needs a DOM opts into happy-dom with a first-line `// @vitest-environment happy-dom`; the rest run in plain node), which no Rust test can reach, and `npm --prefix viewer-ui run build` must leave `viewer-ui/dist` unchanged — the bundle is committed, so a source edit without a rebuild ships a frontend that does not match its source. Both need `node_modules`; the hook says so and moves on rather than failing when Node is absent, since a plain build never needs it. +```bash +cargo fmt --all --check +cargo build +cargo test +cargo clippy --all-targets --all-features -- -D warnings +``` -It does stop, though, for a `node_modules` that is present but no longer the one `package-lock.json` pins — run `npm --prefix viewer-ui ci` and push again. Both gates above pass against whatever happens to be installed, so drift does not fail here, it just makes the bundle they approve the wrong one; CI installs from the lockfile and reports it as a stale bundle listing assets you never touched. +Enable this checkout's hooks once with `git config core.hooksPath .githooks`. The `pre-commit` hook runs the format check; `pre-push` runs the CI-equivalent gates for the changes in the push range. See [commit rules](../.agents/rules/commits.md) for commit-specific policy. -### Verifying on the other platform +The viewer source requires Node.js 22 and installed dependencies: -nightcrow targets macOS, Linux, and Windows, and CI runs the gates on all three. If you are on one platform, the `std::os::unix` / `std::os::windows` cfg gates mean the other platform's code does not compile locally — so a green build on your machine is not proof that the others are green. +```bash +npm --prefix viewer-ui ci +npm --prefix viewer-ui test +npm --prefix viewer-ui run build +``` -Use the Docker gate to run all four gates on Linux from a Windows machine (or vice versa, with the right image): +The bundle in `viewer-ui/dist/` is committed. A viewer change is complete only when the build succeeds and the generated `dist` diff is included when it changes. On Windows, run the Unix verification gate with: ```bash docker compose run --rm unix-gate ``` -`compose.yml` runs `rust:latest` with named-volume caches for the cargo registry and `target/`, so reruns finish in seconds rather than rebuilding every dependency. CI runs the same gates on `ubuntu-latest`, but catching a regression locally avoids the push-and-wait cycle. - -**Known flaky test in Docker**: `a_reattaching_client_makes_an_alternate_screen_program_draw_again` can fail in a container due to PTY timing under load. It passes on `dev` and in CI (`ubuntu-latest`), so it is not a regression signal. +The Docker PTY test can be timing-sensitive under load; rerun the failing test outside the container before treating that failure as a code regression. diff --git a/docs/keybindings.md b/docs/keybindings.md index 8435edd6..a363c482 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -1,109 +1,59 @@ # Keyboard and mouse -## The leader key +`` means the configured leader key. It is `Ctrl+F` by default and can be changed with [`[input] leader`](configuration.md#session-and-client-settings). App commands use the leader followed by one key; ordinary keys, including bare `Ctrl` chords, go to the focused terminal. -nightcrow uses a tmux-style **leader (prefix)** key for its app commands. The default leader is `Ctrl+F` (configurable via `[input] leader`). `Ctrl+F` is a one-handed left-hand chord that avoids tmux's own `Ctrl+B` prefix (so nightcrow stays usable inside a tmux session), terminal flow control (`Ctrl+Q`/`Ctrl+S`), the shell signals (`Ctrl+C/D/Z`), and the Ctrl chords an inner Claude Code pane reserves (`Ctrl+G` is its external editor, plus `Ctrl+O/R/S/T/L`) — its only claimant is `Ctrl+F` as forward-char/page-forward, which most users reach via the arrow keys instead. - -Press the leader, then a single follow-up key. Every other key — including Ctrl chords like `Ctrl+W` and `Ctrl+L` — passes straight through to the focused terminal, so a CLI running there (claude, codex, your shell) receives them unchanged. This is why the leader exists: cockpit users live inside the terminal panes and need their prompt-editing keys to reach the program, not nightcrow. - -The hint bar shows the active leader in caret notation at its left edge (e.g. `^F: leader` for the default `Ctrl+F`), so the configured prefix is always visible from the terminal pane. - -> **Migration from earlier versions:** the old bare-`Ctrl` app shortcuts moved behind the leader. `Ctrl+T/W/L/O/P/Q` are now ` t/w/l/o/p/q` and pass through to the terminal program instead; `Ctrl+F` is now the leader itself (` f` toggles fullscreen). The old `Ctrl+Q`-twice quit confirmation is gone; leave with ` q`, which now detaches rather than ending the session — stop the session itself with `nightcrow stop`. +The prefix waits indefinitely for one follow-up. `Esc` or `Ctrl+C` cancels it. An unmapped follow-up is consumed. Pressing the leader twice sends one literal leader chord to the focused terminal. ## Leader commands -Press ``, then the key. - -| Key | Action | -|-----|--------| -| `` then `` | Send the literal leader to the terminal program | -| ` t` | Open new terminal pane | -| ` w` | Close active terminal pane — terminal focus only, since without it no pane is highlighted as the close target | -| ` s` then `3`…`9`,`0` | Swap the active terminal pane with pane 1…8 (focus follows the pane; same pane numbering as the jump keys, so in terminal fullscreen the swap digits are `1`…`8`) — terminal focus only, like `w`, and needs at least two panes | -| ` z` | Resize the session's terminal panes to fit this screen. A PTY has one size and a program drawing on an alternate screen cannot be re-flowed afterwards, so one screen decides it for the whole session — whichever viewer opened most recently, until another asks. While someone else holds it (a second terminal, or a browser tab) this one renders that grid: padded if it is smaller than the pane, cropped if larger. Advertised in the hint bar only while that is the case | -| ` c` | Give up on the recovery a plugin has pending for a pane — the held slot is released, so nothing can be relaunched into it, and every attached client is told. Targets the focused pane's recovery, or the pane whose process has already ended while its slot was being held (that pane has no tab to focus). Advertised in the hint bar only while something is actually pending | -| ` l` | Toggle between status view and commit log view | -| ` b` | Toggle the read-only file-tree view (returns to status view) | -| ` f` | Fullscreen the focused pane. For the terminal it cycles `off → grid (all panes) → zoom (active pane only) → off`; with a single pane it toggles straight off/on. File list and diff viewer toggle off/on | -| ` o` | Open a repo in a **project tab** (prefilled with the active project's path — type to replace it, or press `→`/`End` first to extend it). `Tab` completes the path against your filesystem and `↓` opens a directory browser (see [Views](views.md#the-repo-dialog)). A leading `~` expands to your home directory. If another tab already has that repo open, nightcrow focuses that tab instead of running two copies against one worktree | -| ` x` | Close the active project tab. Closing the last one leaves nightcrow with no project open, which is a normal state | -| ` p` | Cycle accent color (yellow → cyan → green → magenta → blue). The accent belongs to the session, so every attached TUI and every open browser follows | -| ` u` | Re-read `config.toml` without restarting the session. `[[plugin]]` is re-applied to every open project immediately; `[[startup_command]]` applies to projects you open afterwards, because the panes an open project already started are live processes. Everything else in the file still needs a restart. The result appears on the notice row — see [Reloading the config](configuration.md#reloading-the-config) | -| ` r` | Force a full redraw (clears stray glyphs left by terminal programs) | -| ` q` | Detach — the TUI leaves and the session keeps running, terminals and all. Reattach with `nightcrow attach`; end the session itself with `nightcrow stop` | -| ` 1` / ` 2` | Focus the file/commit list / diff viewer — **split view only** | -| ` 3`…` 9`, ` 0` | Jump to terminal pane 1…8 (`0` addresses pane 8) | -| ` 1`…` 8` (terminal fullscreen) | Jump to terminal pane 1…8. With the viewer hidden the digit row addresses panes by natural numbering; `9`/`0` are unused. The only way back to the list/diff is ` f` to leave fullscreen | -| `Esc` / `Ctrl+C` (while armed) | Cancel the prefix | - -The prefix has no timeout: once armed it waits indefinitely for the follow-up key. A key with no leader binding cancels the prefix and is dropped. - -` s` is the one two-step chord: it arms a swap mode (shown as `SWAP` in the hint bar) that waits for a pane digit, then swaps the active pane with the chosen one. A non-digit follow-up or `Esc` cancels swap mode without reordering. - -## Global (no prefix) - -| Key | Action | -|-----|--------| -| `Shift+→` / `Shift+←` | Cycle focus: file list → diff viewer → terminal panes → … | -| `F1`…`F10` | Switch to project tab 1…10 — see [Projects](projects.md). Unlike the pane digits, this mapping does not change with the layout: the same F-key reaches the same project in every view, fullscreen included | - -A modified F-key (`Ctrl+F1`, `Shift+F5`, …) is not intercepted and passes through to the terminal program. - -## File list / commit list (left panel) - -| Key | Action | -|-----|--------| -| `↑` / `k`, `↓` / `j` | Navigate items one by one | -| `PgUp` / `PgDn` | Jump 10 items | -| `←` / `→` | Scroll long paths and commit summaries horizontally (in tree view these expand/collapse instead) | -| ` f` | Zoom the list pane to full screen (toggle) | -| `/` | Incremental search (status: paths; log: commit summaries; drill-down: paths; tree: recursive filenames) | -| `Esc` | Clear filter, then exit drill-down (log), then cancel search bar | -| `Enter` | Confirm filter (keeps query), drill into commit's file list (log view), or open the selected file fullscreen (tree view) | - -## Diff viewer (right panel) - -| Key | Action | -|-----|--------| -| `↑` / `k`, `↓` / `j` | Scroll one line | -| `PgUp` / `PgDn` | Scroll 20 lines | -| `←` / `→` | Horizontal scroll (4 columns) | -| `v` | Toggle between hunk diff and full file preview | -| `w` | Toggle soft wrapping of long lines. On, the tail of a long line continues on the next row instead of needing `←`/`→`; the line number folds into the line rather than sitting in its own column, so a continuation row carries no number. Horizontal scrolling is inert while wrapping (and the offset resets when you turn it on). The split view ignores wrapping — halves folding to different heights would stop lining up | -| `Tab` | Cycle the display: unified diff → side-by-side split → file contents → unified. `v` and `s` each toggle one view against the unified default, so the third stays hidden unless you know it exists; `Tab` walks all three. Skips the file step when there is no file to open, and does nothing in tree view | -| `s` | Toggle between the unified diff and a side-by-side split view (falls back to unified when the pane is too narrow) | -| ` f` | Zoom the diff/file pane to full screen (toggle) | -| `Enter` | Zoom the diff/file pane to full screen (toggle) — same as ` f` | -| `/` | Open search (works in both diff and file preview, including tree mode) | -| `n` / `N` | Next / previous search match | -| `Esc` | Clear search | - -**Line numbers** are always shown in a pinned gutter. The unified view shows both sides (old, new) — an added line leaves the old column blank, a removed line leaves the new one blank. The split view numbers each half with the side it shows, and the file view (`v`) numbers the file itself. The gutter stays in place while `←`/`→` scroll the code. - -## Terminal panes (bottom) - -Every visible pane renders at once as a split grid instead of switching between tabs — 2 panes go side by side (or stacked if the terminal is narrow), 4 form a 2x2 grid, up to 4 show normally and up to 8 in the fullscreen grid. ` f` cycles the terminal through `off → grid → zoom → off`: *grid* hides the top viewer and fills the screen with the split grid, *zoom* fills the screen with just the active pane. - -The active pane's cell is bordered in the accent color; jumping focus with ` 3`–`9`,`0` or `Shift+←/→` moves that border (and, while zoomed, the pane on screen) without closing any other pane. With more panes than fit, the tab bar shows a `+N` marker for the ones scrolled out of view — they keep running in the background. Keyboard input, paste, and scroll still target only the active pane. A single pane draws with no cell border. - -| Key | Action | -|-----|--------| -| `Shift+↑` / `Shift+↓` | Scroll terminal output 3 lines | -| `Shift+PgUp` / `Shift+PgDn` | Scroll terminal output one page | - -While scrolled, the terminal border title shows `[SCROLL — shift+pgdn: down | input: live]`. Keyboard input is still forwarded to the running process; `Shift+PgDn` to scroll back to the bottom. - -The tab bar picks up OSC 0/2 window-title escape sequences, so programs like `claude`, `vim`, `ssh`, or `cd`-aware shell prompts can rename their own tab. Panes without an emitted title fall back to a default label. +- ` t` opens a terminal pane (up to 8 panes per project). +- ` w` closes the active terminal pane when the terminal has focus. +- ` s`, then a pane digit, swaps the active pane with the selected pane. `Esc` or `Ctrl+C` cancels the second step. +- ` z` claims the terminal size for this screen when another client currently owns it. A PTY has one size shared by all clients. +- ` c` cancels a plugin recovery pending for the focused pane. +- ` l` toggles between status and commit-log views. +- ` b` opens the read-only tree view. +- ` f` toggles fullscreen for the focused list, diff, or terminal panel. Terminal fullscreen cycles through the grid and the active-pane zoom. +- ` o` opens the repository dialog. +- ` x` closes the active project tab. +- ` p` cycles the session accent: yellow, cyan, green, magenta, blue. +- ` u` reloads the configuration; see [Reloading](configuration.md#reloading). +- ` r` forces a full redraw. +- ` q` detaches the TUI; it does not stop the session. +- ` 1` focuses the file list and ` 2` focuses the diff viewer in split view. +- ` 3`…` 9` and ` 0` focus terminal panes 1–8 in split view (`0` is pane 8). +- In terminal fullscreen, ` 1`–` 8` focus panes 1–8; `9` and `0` do nothing. + +## Global keys + +- `F1`–`F10` switch project tabs 1–10. Modified function keys pass through to the terminal. +- `Shift+Left` / `Shift+Right` cycle focus through the file list, diff viewer, and terminal. +- `Shift+Up` / `Shift+Down` scroll the active terminal three lines. +- `Shift+PageUp` / `Shift+PageDown` scroll the active terminal one page. Input remains live while scrolled. + +## File list and commit list + +- `Up` / `Down` and `k` / `j` move the selection; `PageUp` / `PageDown` move by a page-sized step. +- `Left` / `Right` scroll long paths or commit summaries. In the tree they collapse or expand directories instead. +- `/` starts a search. In status it searches paths; in the log it searches commits or drilled-in files; in the tree it searches filenames. +- `Enter` confirms a search, drills into a selected commit, or opens a selected tree file. +- `Esc` clears a search. In the commit log, a second `Esc` leaves a drilled-down file list. + +## Diff viewer + +- `Up` / `Down`, `k` / `j`, `PageUp` / `PageDown`, and `Left` / `Right` scroll the diff. `/`, `n`, and `N` search and move between matches; `Esc` clears the search. +- `v` toggles a changed file's diff and whole-file content when both are available. +- `s` toggles unified and side-by-side diff layouts. +- `w` toggles soft wrapping in the unified/file view. +- `Tab` cycles unified diff, side-by-side diff, and whole-file content when a file is available. +- `Enter` or ` f` toggles diff fullscreen. + +## Repository dialog + +` o` opens a path field. `Tab` completes a directory, `Down` opens the directory browser, and `Enter` opens the selected path. `Esc` closes the browser first and the dialog second. Paths may be absolute, relative to the current directory, or begin with `~`; shell expansion, variables, globs, and files are not accepted. See [Views → The repo dialog](views.md#the-repo-dialog). ## Mouse -nightcrow captures the mouse by default (`[mouse]` in the [configuration](configuration.md#mouse)): +Mouse capture is enabled by default. Click a project tab or panel to focus it; click a terminal pane to focus it and forward the report to programs that requested mouse input. The wheel scrolls the pane under the pointer. Clickable hint-bar commands behave like their key equivalents. -- **Click a pane** to focus it, same as a jump key. The click is also forwarded to programs that asked for mouse reports (Claude Code, `less --mouse`, …) — so their clickable UI, like Claude Code's jump-to-bottom control, works. A plain shell receives nothing. -- **Click the file list or diff viewer** to focus that panel, same as ` 1`/`2`. -- **Click a project tab** in the top row to switch to it, same as its `F`-key. A `+N` overflow marker jumps to the nearest project folded behind it. -- **Wheel** scrolls the pane under the pointer, routed exactly like the scroll keys (wheel reports, arrow keys, or scrollback — whatever the program expects). -- **Click a tab** in the terminal tab bar to jump to that pane; clicking a `+N` hidden-pane marker reveals the nearest hidden pane on that side. -- **Click `o: open project`** on the empty screen — with no project open it is the one action the hint bar offers, and it dispatches like its key. -- **Click a shortcut** in the bottom hint bar to run it — command hints like `t: new pane`, `w: close pane`, or `f: fullscreen` dispatch exactly as if you pressed the keys they name. Clickable hints render inverted (reverse video) across their whole label so they stand out from informational hints; the inversion disappears when `[mouse]` is disabled. Navigation hints and `q: detach` are not clickable (detaching stays a deliberate two-key act). -- **Select text with a bypass modifier + drag.** While the mouse is captured, the outer terminal performs its native selection and copy only when you hold its bypass modifier while dragging. The modifier depends on the terminal: **Shift** in xterm-family terminals (Alacritty, kitty, GNOME Terminal, Windows Terminal), **Option (⌥)** in iTerm2, **Fn or Option** in macOS Terminal.app. Set `enabled = false` under `[mouse]` to give the mouse back to the outer terminal entirely — plain-drag selection returns, click forwarding stops. +While capture is enabled, hold the outer terminal's selection modifier while dragging to select text: `Shift` in xterm-family terminals, `Option` in iTerm2, and `Fn` or `Option` in macOS Terminal.app. Set `[mouse] enabled = false` to restore ordinary outer-terminal selection and disable click forwarding. diff --git a/docs/plugins.md b/docs/plugins.md index e3909fb9..85b3b025 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,65 +1,34 @@ # Plugins -nightcrow itself knows nothing about the CLIs you run in its panes — an agent and a person get the same PTY. Behaviour that *does* need to know a particular tool lives in a plugin: a separate executable that nightcrow launches and talks to over a pipe. +A plugin is a separate executable that receives events from selected terminal panes and may request status updates, input, or a relaunch. Plugins are disabled unless explicitly enabled and opted into; ordinary panes are not exposed. -Plugins are off unless you turn one on, and one only ever sees a pane you handed it by name — or, if you also set `watch_on_signal`, a pane that something running inside it spoke to the plugin from. A plugin is never given a list of your panes either way. +## Install and enable -## The bundled plugin: `nightcrow-recovery` - -Two jobs. It marks a project tab when a pane's agent finishes a turn — Claude Code, via the `Stop` hook below. And when a watched pane's CLI hits its usage limit, it waits for the reset time the provider reported and then re-opens that exact session. It only waits — it does not bypass, raise, or work around any provider limit, and it sends nothing while a limit is in effect. Claude Code, Codex CLI, and OpenCode are supported; OpenCode is only ever observed, never interrupted, because it retries on its own. +`nightcrow plugin install` copies an executable to `~/.nightcrow/plugins` and prints a configuration snippet. It does not edit your config or enable the plugin. ```bash -cargo build --release -p nightcrow-recovery -nightcrow plugin install target/release/nightcrow-recovery --name recovery -nightcrow plugin list # what is installed, and how config refers to it -nightcrow plugin remove recovery +nightcrow plugin install PATH [--name NAME] [--force] +nightcrow plugin list +nightcrow plugin remove NAME ``` -`install` prints the exact `[[plugin]]` block to paste, using whatever `--name` you chose — that name is what a pane opts in with, so keep the two in step. - -## Enabling one +Declare and enable the plugin in `~/.nightcrow/config.toml`, then set its name on a `[[startup_command]]` pane. The complete field reference and an example are in [Configuration → `[[plugin]]`](configuration.md#plugin). `args` are passed verbatim and `[plugin.env]` affects only the plugin process. Plugin names must be unique. `allowed_resume_flags` is an allowlist for arguments a plugin may append when relaunching a configured pane; an empty list forbids relaunch arguments. -Installing only puts the binary in `~/.nightcrow/plugins`. It stays inert until you edit `~/.nightcrow/config.toml` yourself — enabling something that can type into a terminal should be a change you read before it takes effect: - -```toml -[[plugin]] -name = "recovery" -command = "nightcrow-recovery" -enabled = true -# Resume flags or subcommands the plugin may append. Empty by default, which -# refuses every relaunch with arguments. nightcrow cannot know what a CLI's -# control tokens mean, so it will not add one you did not list — that is what -# keeps a plugin from changing how a CLI asks for your approval. -allowed_resume_flags = ["--resume", "resume", "--session"] - -[[startup_command]] -name = "Claude" -command = "claude" -plugin = "recovery" # without this line, no plugin sees this pane unless - # watch_on_signal is set (see below) -``` +Set `watch_on_signal = true` to allow a process started inside an unconfigured pane to opt in using its pane token. This is off by default. Such a pane can be monitored and receive plugin input, but cannot be relaunched because nightcrow did not start its command. A plugin never receives a list of panes and cannot address one that has not opted in. -## Panes you opened by hand +Changing plugin configuration with [config reload](configuration.md#reloading) applies it to open projects. Replacing `command`, `args`, or `env` restarts the plugin; any recovery that was pending in that process is abandoned. Disabling or removing a plugin stops watching its panes but leaves the terminal programs running. -That covers the panes you configured. For the pane you did not — you opened a shell with ` t` and typed `claude` into it yourself — add `watch_on_signal = true` to the `[[plugin]]` block. +## Bundled `nightcrow-recovery` -nightcrow puts a random token in each pane's environment and nowhere else, so the CLI's own hook can quote it back and the plugin can ask for "the pane this token names"; a plain shell never speaks to a plugin, so your shells stay untouched. It is off by default. Such a pane can be waited for and typed into but never relaunched — nightcrow launched no command in it, so there is nothing to put back. - -## Claude Code hooks - -For Claude Code, let the plugin install its hook and statusline entries so it can read the exact session id and reset time instead of guessing from what is printed on screen. With a reset time it waits exactly once; without one it falls back to retrying on a backoff, which can give up. It merges into your existing `~/.claude/settings.json` and backs it up first: +Build and install the bundled plugin from a checkout: ```bash -nightcrow-recovery install-hooks -nightcrow-recovery uninstall-hooks # removes only what it added +cargo build --release -p nightcrow-recovery +nightcrow plugin install target/release/nightcrow-recovery --name recovery ``` -Claude Code's `statusLine` holds one command, so installing does replace yours — but it is then run from the plugin's own statusline with the same input, and what it prints is what you see. `uninstall-hooks` puts it back. - -Installing also adds a `Stop` hook, which fires as every turn ends and marks that pane's project tab (see [Projects](projects.md)). This exists because the marker is otherwise inferred from what crosses the PTY — a terminal bell, or a burst of title changes — and Claude Code reports a finished turn through desktop notifications instead, which cross neither. The hook says so directly, so the marker no longer depends on how long the turn was or how often the title moved. It carries no payload: that the turn ended is the whole message. - -## Cancelling a pending recovery +The plugin recognizes Codex CLI and OpenCode. Codex recovery reads the pane's rollout JSONL, requires an unambiguous session id, and relaunches with `codex resume ` after the process exits; it never uses `--last`, which could select another pane's session. OpenCode polls `/session/status` and remains hands-off while the provider reports `retry`. When a live process becomes `idle`, recovery reports `NeedsAttention` without interrupting it. If the process exits, the exact session can be relaunched with `--session `. -A pane that is waiting shows its state and deadline on its tab. Cancel it with ` c` (see [Leader commands](keybindings.md#leader-commands)), or from the web viewer; typing into the pane yourself also cancels it. +## Recovery controls -Design and trust boundary: [Architecture → Plugin host](architecture/plugin-host.md). +A pending recovery is shown on the pane tab and in the browser. Use ` c` or the browser control to cancel it. Typing into the pane also cancels the pending recovery. A cancelled recovery does not relaunch the pane. diff --git a/docs/projects.md b/docs/projects.md index fe0376c5..d8a2980d 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -1,19 +1,13 @@ # Projects -One nightcrow process holds up to **10 repositories at once**, each in its own tab across the top row. A project owns everything scoped to its repo — the git views, the snapshot worker, and its own set of terminal panes — so switching tabs swaps the whole screen, not just the diff. A pane running a build in one project keeps running while you work in another. +The session can serve up to 10 repositories. Each repository is a project tab with its own status, commit-log, tree, and terminal views. A project can hold up to 8 terminal panes; its panes keep running while another project is active. -``` - F1 nightcrow F2 api-server +3 ← project tabs (active one accented) -┌ ^F 1 Files ──────┐┌ ^F 2 src/main.rs ────┐ -``` +Open and close projects with ` o` and ` x`; switch among tabs with `F1`–`F10`. Opening a repository that is already open focuses the existing tab instead of creating a duplicate worktree view. The browser and every attached TUI share the project set, order, and active project. -- `^F o` opens a repo in a tab, `^F x` closes the active one, and `F1`…`F10` switch between them. There is no "change this tab's repo": closing and opening is the same thing, and it tears the old project down properly instead of leaving its shells behind in the previous directory. -- Opening a repo another tab already holds focuses that tab instead of running two copies against one worktree. -- When the tabs outgrow the row, it scrolls around the active tab and folds the rest behind `+N` markers; clicking a marker jumps to the nearest project behind it. -- A blinking `•` marks a background project whose terminal needs attention. Opening that project acknowledges everything seen so far; later activity can light it again. A terminal bell raises it, as does a pane exiting or a burst of title changes settling. A tool that reports finishing some other way can say so directly through a plugin — see [Plugins](plugins.md#claude-code-hooks). +If tabs do not fit, the tab row folds inactive tabs behind an overflow marker. A background project shows an attention marker when its terminal reports unread activity; selecting that project acknowledges the marker, and later activity can raise it again. -**No project open** is a normal state, not an error — it is how a fresh session starts, and where closing the last tab returns you. The screen keeps its chrome and offers the only two things that apply: `^F o` to open a repo, `^F q` to detach. +Having no project open is valid. A new session starts there when no repositories are saved, and closing the last tab returns there. Use ` o` to open a repository. -Each project keeps its own session file (see [Session state](session-state.md)), so tabs restore independently. +Repository paths are normalized to their worktree root, so opening a subdirectory of an already open worktree focuses the existing project. The path dialog supports `~`, absolute paths, relative paths, completion, and a directory browser; see [Views → The repo dialog](views.md#the-repo-dialog). -Typing a path into the repo dialog, completing it with `Tab`, and browsing for one with `↓` are covered in [Views → the repo dialog](views.md#the-repo-dialog). +Open tabs and the active tab are session-owned. Per-project selection, scroll, view mode, and fullscreen state are client view state; see [Session state](session-state.md). diff --git a/docs/session-state.md b/docs/session-state.md index 97c84896..2319972d 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -1,25 +1,19 @@ # Session state -## Recent-activity focus indicator +## Recent activity -Files modified within the last `hot_window_secs` seconds — whether by an agent in a terminal pane, your editor, or a build/format script — are rendered in the accent color (bold for the first 5 seconds, normal until the window expires). +When `[agent_indicator].enabled` is true, files changed within `hot_window_secs` (15 seconds by default) are highlighted in the status list. They are bold for the first 5 seconds, then use the accent color until the window expires. This includes changes made by editors, builds, and terminal programs, not only AI tools. -When the file list is in focus and you have not navigated in the last 2 seconds, the selection auto-follows to the freshest hot file so the diff updates as files change. Manual navigation (`j` / `k` / arrows / PgUp / PgDn) immediately suppresses auto-follow until you go idle again. +With `[agent_indicator].auto_follow = true`, the status selection moves to the freshest hot file after 2 seconds without manual navigation. Moving the selection suppresses auto-follow until the next idle period. The indicator is shared by the TUI and browser and uses the server's setting. -Configurable under [`[agent_indicator]`](configuration.md#agent_indicator). +## Files and ownership -## What persists +State is stored under `~/.nightcrow/`; nightcrow does not write session state into a repository. -nightcrow saves the current state on exit and restores it on the next launch — focus position, selected file, scroll offset, active terminal pane, view mode (status / commit log / tree), fullscreen states, commit-log drill-down position, and tree expansion and selection. +- `workspace.json` stores the daemon's open repositories, tab order, and active tab. It also stores up to 50 recently used repositories' TUI view state: selected file, focus, scroll, active pane, view mode, commit-log position, tree selection/expansion, and list/diff/terminal fullscreen state. A terminal fullscreen restore returns to the grid; a zoomed pane is not persisted. +- `viewer.json` stores the session accent and browser layout preferences, including sidebar width, upper-panel split, per-project view, and maximized panel (up to 50 recent projects). Browser terminal panes and their live arrangement end with the session. +- `sessions` stores authenticated web-viewer tokens so browser logins can survive a daemon restart. Logout revokes a token server-side. Removing this file prevents tokens from being restored on the next restart; a running daemon keeps its in-memory tokens until they expire or are logged out. -The accent is not in that list. It belongs to the session rather than to one repo's view state, so it lives in `~/.nightcrow/viewer.json` alongside the viewer's other shared preferences and is not restored per repo. +The daemon owns the repository set and active tab. An attached TUI writes its own selection and view state when it detaches or the connection ends, without overwriting the tab list. Browser repository changes update the shared workspace. Closing every project before stopping writes an empty set, so the next session starts empty. -The browser keeps its own half of this. Which panel each project is maximized in is remembered per project in `viewer.json`, so a refresh comes back to the layout you left — the browser's counterpart to the fullscreen states above, kept apart from them because maximizing on a 40-row terminal and in a browser window are not the same answer. It is held for 50 projects, like the TUI's — the 50 whose arrangement was set most recently, so maximizing a fifty-first is what drops the oldest, not merely opening one. - -Everything else lands in one file, `~/.nightcrow/workspace.json` — which repos were open, which tab was in front, and each repo's view state. Nothing is written inside your repositories: no single repo owns the fact that others were open beside it, and nightcrow shouldn't create directories in a project it is only reading. - -A bare `nightcrow` reopens those tabs and lands on the one that was in front, with each project's selection and scroll where you left them. Repos that have moved or been deleted since are skipped, with a notice saying how many. View state is kept for the 50 most recently used repos. - -## Who writes what - -The two halves have two owners. The session writes which repositories are open and which tab is in front; an attached client writes what it had selected and scrolled, and never the tab list — detaching must not roll the session back to one client's view of it. To start empty, close every tab before stopping the session. +Corrupt or missing JSON state falls back to defaults. A repository that is no longer a directory is not started on the next daemon launch. Reopen it through the project dialog when it is available again. diff --git a/docs/views.md b/docs/views.md index 6091ebcc..d53d8eb5 100644 --- a/docs/views.md +++ b/docs/views.md @@ -1,71 +1,29 @@ # Views -## Status view - -The default. Lists changed files on the left, syntax-highlighted diff on the right. - -Each row begins with a two-character `XY` status code, following Git's short status notation (nightcrow reads status through git2 internally, not by parsing `git status --short`). `X` is the staged (index) state and `Y` is the unstaged (working-tree) state, so a file can show both at once: - -| Code | Meaning | -| --- | --- | -| ` M` | modified, unstaged | -| `M ` | modified, staged | -| `MM` | modified, staged **and** further modified in the working tree | -| `A ` | added (staged) | -| `D `/` D` | deleted (staged / unstaged) | -| `R ` | renamed (shown as `old -> new`; searchable by either path) | -| `T ` | type changed (e.g. file ↔ symlink) | -| `??` | untracked | -| `UU` | conflicted (placeholder for unmerged paths) | +Each project has a status view, commit log, and read-only tree. The upper area contains the selected list and diff/file content; terminal panes occupy the lower area. Use [Keyboard and mouse](keybindings.md) for navigation. -The diff for a selected file shows the combined working-tree-with-index changes. - -## Commit log view (` l`) +## Status view -A tig-like commit list on the left, full commit diff on the right. Commits ahead of the upstream are marked with `↑`. Press `Enter` on a commit to drill into its individual files; `Esc` to go back. +The left list contains changed paths and the right pane shows the selected working-tree diff, with syntax highlighting and line numbers. Rows use Git's two-character `XY` status: `X` is the index (staged) state and `Y` is the working-tree state. For example, `MM` is staged and further modified, `??` is untracked, and `UU` is conflicted. Renames show both paths and can be found by either name. -The list auto-refreshes when the workdir HEAD changes (commits made in the terminal pane, amends, force-pushes, branch switches). History loads one page at a time — initial entry fetches `commit_log_page_size` commits and additional pages stream in on a background thread as the selection approaches the loaded tail, so deep histories stay responsive. Toggling while a terminal or diff pane is zoomed exits the zoom and focuses the list, so the view switch is always visible. +## Commit log view -## Tree view (` b`) +` l` shows a commit list and the selected commit's diff. Commits ahead of a tracked upstream are marked with `↑`; a commit with no upstream has no ahead/behind marker. `Enter` drills into the commit's changed files, and `Esc` returns to the commit list. -A read-only directory tree of the whole working tree on the left, with the selected file's raw contents on the right. Unlike the status view (which lists only changed files), the tree lets you browse and read *any* file next to the diff without leaving nightcrow. +History loads in pages. The first page and subsequent prefetch distance use [`[log]`](configuration.md#log) settings, and scrolling near the end requests more. The view follows a new `HEAD`; a history rewrite replaces the list and may close a drill-down. -- `j`/`k` move the cursor, `→` expands a directory (read lazily, one level at a time), `←` collapses it or steps up to the parent, and selecting a file previews it. -- `Enter` on a file row opens it in the preview pane and zooms that pane fullscreen (`Enter` again, or ` f`, exits the zoom); on a directory row it does nothing. -- `/` while the tree is focused runs a recursive filename search across the whole tree — type to filter, `Enter` reveals the selected match in place (expanding its ancestor directories), `Esc` cancels. -- Focus the file preview with ` 2`, then press `/` to search within the file contents — `n`/`N` jump to the next/previous match, `Esc` clears the search. +## Tree view -`.gitignore`-matched paths (e.g. `target/`, `node_modules/`) are hidden by default — toggle with `[tree] respect_gitignore`. Expanded directories are watched for filesystem changes, so files and folders created, moved, or deleted by another process (an editor, `git`, an LLM CLI) appear without leaving the view; set `[tree] live_watch = false` to refresh only on entry instead. See [Configuration → `[tree]`](configuration.md#tree). +` b` opens a read-only directory tree for the whole worktree. Expand with `Right`, collapse or move to the parent with `Left`, and press `Enter` on a file to preview its contents. `/` searches filenames recursively; `Esc` cancels a search. The preview pane supports content search with `/`, `n`, and `N`. -The tree never writes, renames, or deletes anything. Expansion state and the selected path persist across sessions. +Paths matched by `.gitignore` are hidden by default. `[tree] respect_gitignore`, `[tree] max_depth`, and `[tree] live_watch` control filtering, expansion depth, and whether expanded directories refresh on filesystem changes. The tree never writes, renames, or deletes files. ## Notice row -A one-row strip just above the hint bar shows the repo path (home-relative, e.g. `~/projects/myapp`), the current branch, and ahead/behind counts (`↑N ↓M`) when the branch tracks an upstream. A path or a branch too long for the row is cut with `…` — the counts and the recovery chip after them keep their room, so a long name shortens itself rather than pushing them off the end. - -When something fails — a git snapshot, a diff load, a terminal pane, or a repo path you typed that doesn't exist — the message takes over this row in red until the problem is resolved or you act on the app again. - -While the repo dialog is open, its input takes this row in the header's place — you are deciding which repo the header will name next — and the messages move down to the hint row: a rejected path appears directly below the input you're correcting, and the dialog's completion candidates show there too (dimmed, and a notice outranks them), so a list too long for one line ends in `+N more`. -When neither is up, the hint row spells out the dialog's keys. +The header identifies the selected repository, branch, and tracked-branch ahead/behind counts. Errors from Git, a diff load, terminal creation, or repository selection appear in the notice row until resolved or dismissed by app input. Repository-dialog validation messages appear below the dialog. ## The repo dialog -### Path completion - -`Tab` completes the directory you're typing, so you don't have to know the path by heart. One press extends as far as the names allow; when there's nothing left to extend it lists what's there instead. On a trailing `/` the first press shows that directory's contents, and a unique match gains a trailing `/` so you can keep pressing `Tab` to descend. - -Only directories are offered (a file can't be a repo), dotted directories stay hidden until you type a leading `.`, and a name that differs only in case is matched and corrected for you. The dialog is a path field, not a shell — `~`, `..` and paths relative to your working directory all work, but `cd`, `$VAR`, and globs don't, and `Enter` always means "open this path". - -### Browsing for a repo - -When you don't know the path, press `↓` in the repo dialog to browse instead of typing. (A second `Tab`, once the candidate list is up, opens the same browser: at that point the flat list has told you all it can.) The browser fills the body of the screen, rooted at whatever directory the field currently names, and the field stays visible below it with the keys spelled out. - -| Key | Action | -|-----|--------| -| `↓` / `j`, `↑` / `k` | Move the cursor | -| `→` | Expand the selected directory (read lazily, one level at a time) | -| `←` | Collapse it, or step out — to the parent row, or one level *above the root* when you're already at the top, so a sibling checkout is one press away | -| `Enter` | Take the selected path into the field and return to it — this does **not** open the repo. Press `Enter` again in the field for that, or keep refining the path with `Tab` first | -| `Esc` | Leave the browser, keeping the text it started from. A second `Esc` cancels the dialog | +Open it with ` o`. The field accepts an existing directory path, including absolute paths, paths relative to the current directory, and a leading `~`. It is a path field, not a shell: `cd`, environment variables, and globs are not expanded. An empty or nonexistent path is rejected and leaves the dialog open for correction. -Directories only, hidden ones excluded, and nothing is ever written. Note that `Enter` means *select* here but *open* in the field — the browser's job is to fill the field, so `→` alone expands — matching the file-tree view, where `Enter` opens a file rather than expanding. Paths keep your own notation: browsing out of `~/coding` gives you back `~/coding/…`, not an absolute path. Mouse selection isn't supported; the browser is keyboard-only. +`Tab` completes directory names. `Down` opens a keyboard-only directory browser; it lists visible directories, and `Right`/`Left` expand and collapse. `Enter` in the browser selects a directory into the field; `Enter` in the field submits it. `Esc` closes the browser first and then cancels the dialog. Opening a directory inside an existing worktree resolves to that worktree; a directory outside Git shows a repository error when its views load. diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 5e099968..cada74dd 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -1,128 +1,35 @@ # Web viewer -A browser surface that renders the same git data as a native web page — selectable text, real scrolling, clickable paths, and a layout that adapts to a phone. It also serves the session's terminals, the same panes an attached TUI sees. +The web viewer is always served by a session. It shows the same repositories, project tabs, terminal panes, and session accent as the TUI, at the URL printed when the daemon starts. The browser is another client of the session, not a separate copy. -It is always on — it is one of the session's two faces, not an add-on. +## Projects and files -## Projects in the browser +The header's project control opens an existing server-side directory, closes a project, or reorders tabs. The same picker can clone a remote repository into the selected directory. Cloning runs `git` on the server and uses that machine's SSH agent or credential helper. -The served repositories appear as project tabs in the header — `+ open` browses the server machine's folders to add one, `×` closes it, and dragging a tab reorders them. +Only `https://`, `http://`, `ssh://`, `git+ssh://`, and scp-style `user@host:path` remotes are accepted. Local paths, `file://`, `git://`, and `ext::` are refused. One clone runs at a time; it continues on the server if the page is closed or reloaded, and the page can resume polling it. A destination with an existing name is rejected. -The same dialog **clones a git URL** into the folder it is showing: paste `https://…` or `git@host:path`, and the repository opens as a tab when the clone finishes. Cloning runs `git` on the server, so it uses that machine's credentials — an SSH agent, a credential helper — and a private remote works exactly as it would in a shell there. Local paths and git's `ext::` transport are refused. A clone keeps running whether or not you stay to watch it: closing the dialog leaves `Cloning…` in the header, and a page you reload — or a phone that dropped the tab mid-transfer — picks the same clone back up and still opens the repository when it lands. +Each project exposes `status`, `log`, and `tree` views, a diff/file content pane, and the same interactive terminal session as an attached TUI. The [Views](views.md) and [Keyboard and mouse](keybindings.md) guides describe the shared Git and input behavior. Browser view state (last tab/file, tree expansion, and maximized panel) is stored separately from TUI view state. -Each project has its own `status`, `log`, and `tree` tabs on the left plus a terminal panel below. The order is kept on the server, so every device shows the same arrangement, and it survives a restart (alongside the TUI it lasts the session). On a narrow window the tab row folds into a dropdown showing the current project. +Markdown files render as formatted documents with highlighted fenced code. `.html` and `.htm` files can render in a sandbox that allows inline scripts but blocks cookies, session access, network connections, and external assets; use the raw-source toggle for inspection. The rendered page is a preview of a self-contained file, not a general website. -## Views +## Layout and terminals -**A project opens onto what it was last showing.** The tab you were in, the file you had open, and the directories the tree had expanded come back when you open that project again — on the next visit, after a reload, and on whatever device you pick up next, since the server keeps it. The TUI has done this since it had a session file; this is the same idea in the browser, kept in the viewer's own file rather than the TUI's, so the two do not overwrite each other. A file that has gone since you left simply does not open: the project comes back to its list, not to an error, and keeps asking for it next time — the server answers a deleted file and one it could not read the same way, so forgetting on the first sign of trouble would throw away a perfectly good memory. On a phone, restoring does not move you: whichever of the three views you were on is the one you stay on, with the file waiting behind it. +Drag the sidebar and upper-panel dividers to resize them; double-click a divider to reset it. The browser's sidebar width and upper-panel split are stored in `~/.nightcrow/viewer.json` and shared with other browser clients. They are independent of the TUI's `[layout]` values. The header swatch cycles the session accent and is shared with attached TUIs. -In the `log` tab, selecting a commit opens its changed-file list alongside the complete commit diff. Select a file to view only that file's change; use `< log` to return or `all changes` to restore the complete commit diff. +The terminal toolbar can add a pane, show panes as a grid or tabs, maximize the terminal panel, claim sizing for this screen, and show the on-screen key bar. A project has up to 8 panes. Pane order and zoom are shared while the session runs; they are not restored after the session ends. A PTY has one size, so the client that most recently claims sizing determines the grid rendered by every client. -History loads a page at a time, as the TUI's does — scrolling toward the end of the list fetches the next page, so deep histories stay reachable without loading them up front. The filter narrows the commits already loaded rather than searching the server, so paging pauses while a query is up — the list says how many are loaded, and clearing the filter resumes loading. The list follows HEAD the way the TUI's does: a commit made in the terminal panel below appears at the top on its own, without disturbing the pages you have scrolled through. A rewrite of the history you were reading — a rebase, an amend — replaces the list with the new history instead, and closes a commit drill-down whose commit it swept away. +On phones and other narrow layouts, the bottom navigation switches among `Repo`, `Content`, and `Terminal`. Touch-dragging a terminal scrolls it; the key bar supplies Escape, Tab, arrows, and control keys when a soft keyboard cannot. Its `Ctrl` button is a latch for the next typed character. The keyboard-bar preference is stored in the browser, so it can be changed from the terminal toolbar. -With a diff showing, the content pane has a toggle (top-right) that switches between the inline unified diff and a side-by-side split view, mirroring the TUI's `s`. The choice lasts the page, the same lifetime the TUI gives it; on a narrow window the two sides stack — removed above added — rather than sitting side by side, since neither column would have the width to read. +Terminal programs may write to the clipboard through OSC 52; the text reaches the browser device viewing the pane. A program requesting clipboard contents is not answered. If the browser requires a user gesture to write, the viewer shows a Copy action. -Beside it, a **whole file** toggle swaps the diff for the file it belongs to, opened at the change that was on screen — the browser's half of the TUI's `v`. It shows the file as the commit left it when you reached the diff from the log, and the working copy when you reached it from the status list, so what you read is what the diff was describing. Press it again for the diff. It appears only where there is a second face to show: a whole-commit diff spans several files, so "which one" has no answer, and a file opened from the tree has no diff behind it. The TUI draws the same two lines. +## Access and security -**Line numbers** ride in a pinned gutter as they do in the TUI: the unified view shows both sides (old, new), leaving a column blank where the line does not exist on that side; each split half shows the side it renders; and a file opened from the tree is numbered by its own lines. The gutter stays put while the code scrolls sideways, and the numbers stay out of anything you copy. +Configure the listener and credential in [`[web_viewer]`](configuration.md#web_viewer). The default is `127.0.0.1:8091` over plain HTTP. An authenticated viewer grants repository browsing and interactive shell access. For remote use, do not expose the port directly: tunnel it with SSH or put it behind a TLS reverse proxy. -The `status` list highlights recently touched files the same way the TUI does: accent-coloured and bold for the first 5 seconds after a file's mtime, accent until `agent_indicator.hot_window_secs` expires, then plain. The window (and whether the highlight runs at all) comes from the server's `[agent_indicator]` settings, so both surfaces fade on the same schedule. Ageing is measured against the browser's clock, so a device whose time is badly off will fade early or late. +If no password or `hashed_password` is configured, the daemon generates a random password, saves it to `~/.nightcrow/config.toml`, and prints it once at startup. `hashed_password` accepts an Argon2 PHC string and takes precedence over `password`. Login attempts are rate-limited and issue an HTTP-only, SameSite cookie; logout revokes the server-side token. Tokens are persisted in `~/.nightcrow/sessions` and use the configured `session_ttl_hours` (`24` by default, `0` for no server-side expiry). See [Configuration](configuration.md#web_viewer) for limits and reload behavior. -Markdown files (`.md`, `.markdown`) opened from the tree render as formatted documents by default, with fenced code syntax-highlighted. HTML files (`.html`, `.htm`) render too, inside a sandboxed frame that allows the document's own inline scripts and nothing else — so an interactive single-file page works (a slide deck's keyboard navigation, a chart that draws itself), while the frame stays cut off from the session: it runs as no origin, its requests carry no login, and nothing loads from or connects to another host. A page that carries its scripts and styling inline and embeds images as `data:` URIs runs in full; one that links a stylesheet, images, or scripts as separate files (or from a CDN) shows without them. This previews a self-contained page rather than a site. A toggle (top-right of the pane) switches either back to the raw highlighted source. Click the frame first if keys seem to go nowhere — the keyboard follows focus. +The viewer checks the request host and origin before serving repository data. Repository/path errors are redacted where exposing server paths would be unsafe; clone failures may include actionable Git output. The HTML preview is sandboxed and cannot use the viewer's session or connect back to it. -## Layout +## Frontend development -The swatch in the header cycles the accent colour through the same five presets as the TUI's ` p` (yellow → cyan → green → magenta → blue) — and it is the same colour, not a parallel one. The choice is stored on the server (`~/.nightcrow/viewer.json`), so every device that opens the viewer and every attached TUI shows it, and a change made anywhere reaches the browsers within a few seconds and attached terminals immediately. `[theme] name` sets the colour a session starts with, before anyone has picked one. - -Drag the divider between the sidebar and the content pane to resize the sidebar, or double-click it to reset the default width. The width is stored on the server the same way as the accent, so every device opens at the same split; it is bounded so the content pane always keeps at least half the window. - -The border between the upper panel and the terminal panel is a divider too: drag it to give the terminal more or less of the window, double-click to go back to the default 55/45. It is stored on the server like the sidebar width, so every browser opens at the same split, and bounded so neither panel shrinks to a sliver — for "all the way" use the maximize buttons on either panel. Unlike the accent, this one is **not** shared with an attached TUI: the TUI keeps its own `[layout] upper_pct`, because the same percentage means a different number of rows on a terminal than in a browser window, and the terminals' actual size is already decided by whichever client owns the sizing. - -## Terminals - -Each terminal pane's toolbar has a **fit to this screen** button, the browser's half of the TUI's ` z`. It is offered only while another screen holds the sizing, because a PTY has one size for the whole session: the panes are fitted to whichever viewer opened most recently, and everyone else renders that grid until someone asks for it. Switching projects does not move it, and neither does a dropped connection coming back — a tab is one screen however many sockets it opens. Reloading the page counts as opening it, so it takes the sizing again, as a new tab would. - -Nobody holding the sizing is a state for a session with nobody in it. If every screen goes and one comes back — a phone that slept long enough for its socket to die — it takes the sizing rather than returning as a spectator, because there is no other screen to take it from. - -The panel draws its panes either side by side, as the TUI does, or one at a time behind a tab strip. The button beside **+** switches between the two, and a narrow screen starts on tabs — a split grid gives each pane fewer columns than a command line needs. Once you pick, that choice sticks on that device, rotation included; it is stored in the browser rather than on the server, because what a phone should do with four panes is not what the desktop beside it should do. - -Tabs change nothing about the session: **+** still opens a terminal that every client sees, the tabs sit in pane order, and a tab you are not looking at is a running program whose output keeps arriving. Every pane is also held at the panel's full size while tabbed, so switching tabs costs no resize — which is the same reason a tabbed browser and an attached TUI cannot both be right about how wide a pane is. Give the sizing to whichever screen you are working on with the button above, or leave the TUI holding it and read the panes at its width. - -A tabbed panel shows no **zoom** button — it already shows one pane — and a zoom another client set does not move the keyboard here. - -Drag a terminal pane by its header, or by its tab, onto another to reorder them; it works with touch as well as a mouse. The order is kept on the server, so a refresh, a reconnect, or another device opening the same repository all show the same arrangement. (It is not written to disk — a server restart clears the terminals themselves, so there is nothing to persist.) - -The **zoom** button on a pane's toolbar fills the panel with that one terminal, and the keyboard follows it. Like the order, and for the same reason, it is kept on the server: a refresh comes back to the pane you had zoomed, and another device showing the same project follows. Opening a terminal ends the zoom — the new one would be behind it otherwise — and so does closing the zoomed pane. It is not written to disk either, and cannot be: a zoom names a pane, and restarting the session ends the panes. An attached TUI keeps its own ` f` zoom rather than following this one — the panes are shared, but what fills a screen is that screen's. - -To copy from a pane, select with the mouse and press the copy key your browser already uses. A plain drag selects only while the pane's program is not reading the mouse itself; most full-screen programs do read it, and then a drag is theirs — that is how clicking a menu in one of them works at all. Hold a modifier to take the drag back for a selection: **Option** on a Mac, **Shift** everywhere else. There is nothing to copy until something is selected, so without the modifier the copy key looks broken rather than empty. - -A program running in a pane can also copy on its own — Claude Code's `/copy`, vim's OSC 52 clipboard, tmux's `copy-pipe`. That copy reaches *this* page, not the machine hosting the session, which is what makes it worth having: the `pbcopy` such a program also runs writes to a clipboard nobody at this end can reach. Most of the time it simply happens, including over plain `http://`. - -When the browser refuses to fill the clipboard without being asked — Safari wants a press for it, and any browser may — a notice appears with a **Copy** button instead, and pressing it is the press it wanted. It stays up until the text is across, so it is still there if you come back to it. - -A program asking to *read* the clipboard is never answered. It would hand whatever was last copied — a password, a token — to whatever is running in the pane, and unlike writing that is something a program could not otherwise get. - -## On a phone - -The three regions the desktop shows at once — the sidebar, the content pane, and the terminal — would each shrink to an unusable sliver stacked in one column, so instead a bottom bar switches between them: tap **Repo**, **Content**, or **Terminal** to give one of them the whole screen. The labels name the regions rather than what is in them: the sidebar is `status`, `log`, or `tree`, and the content pane holds a diff, a whole file, or nothing yet. Opening a file or commit jumps to the content pane automatically. - -**Drag a pane to scroll it.** A finger dragged up or down the terminal turns the same wheel a mouse would, so where it goes is up to the program in the pane: an agent or a pager that reads the wheel itself scrolls its own view, `less` and `man` get the arrow keys they expect under alternate scroll, and a plain shell scrolls the emulator's scrollback. That routing is the browser terminal's, matching what the TUI does with `Shift+↑/↓` — which is why a full-screen program that keeps its transcript in its own memory scrolls at all, rather than dragging an empty scrollback around. A short drag is still a tap, so tapping to place the cursor and pinching to zoom both survive. - -Because a soft keyboard can't type Escape, Tab, Shift-Tab, Ctrl combinations, or the arrows, the terminal grows a key bar along its bottom on touch devices that sends those straight to the shell — so you can interrupt a process (`^C`), leave `vim` (`Esc`), reach a tmux session's prefix (`^B`), cycle a completion menu backwards (`⇧Tab`), or walk your history (arrows) without a physical keyboard. - -**`Ctrl` on the bar is a latch, not a key.** More combinations matter than there are buttons for, so tapping `Ctrl` lights it up — and puts the keyboard back in the pane, since what spends it is the next character you *type* — after which that character leaves as the combination: `Ctrl` then `a` is `^A`, and so on for anything a terminal has a control byte for, `Ctrl+Space` and `Ctrl+[` included. Type something with no such byte — Hangul, an emoji, more than one character — and it goes through as you typed it. Some input leaves the latch alone altogether — an Escape or an arrow from a hardware keyboard — because what the program in the pane reports back to the browser arrives looking the same, and a latch spent on that would die before you typed anything. So the light is what to read: `Ctrl` is armed for exactly as long as its button is lit, and tapping it again, tapping any other key on the bar, or hiding the bar puts it out. - -**The bar is not a phone-width thing** — a tablet is as wide as a laptop and types the same way, so what turns it on is the pointer: any device whose primary pointer is a finger gets it, at any width, along with every window narrower than 768px. The keyboard button in the terminal panel's toolbar turns it off and on from there, and this browser remembers which — so a desktop that wants the keys anyway can keep them, and a tablet with a hardware keyboard attached can drop them. - -The viewer ships a web-app manifest and icons, so you can **add it to your home screen** and launch it as a standalone, chrome-less window — more room for the terminal and one-tap access. On iOS this works over plain HTTP (Safari → *Share* → *Add to Home Screen*). Android's install prompt additionally wants a service worker and a secure origin, so reach the viewer over HTTPS (a reverse proxy or tunnel) to get it there; the viewer has no offline mode either way — every screen needs the server. - -## Configuration and access - -Configure where it listens under `[web_viewer]`: - -```toml -[web_viewer] -bind = "127.0.0.1" # loopback only; change deliberately -port = 8091 -# password = "..." # auto-generated and written here on first launch if unset -session_ttl_hours = 24 # how long a login lasts; 0 = never expires -``` - -`--port` and `--bind` override those for one run: - -```bash -nightcrow --port 9000 -``` - -Repositories opened or closed in the browser reach every attached terminal, and are written back to `~/.nightcrow/workspace.json` so the next session starts on the same set. - -**Authentication.** If no `password` is set when the viewer is enabled, a random one is generated and written back into your config (so it survives restarts and stays readable) and printed once at startup. To avoid a plaintext password on disk, set `hashed_password` to an Argon2 PHC string instead — it takes precedence. Login is rate-limited and grants a session cookie. Sessions survive a daemon restart: tokens are persisted to `~/.nightcrow/sessions` with owner-only file permissions. Logout revokes the token server-side, so clearing the cookie alone is not enough to invalidate a session. - -**How long a login lasts** is `session_ttl_hours`, 24 hours by default. `session_ttl_hours = 0` means it never expires on its own — logging out, or deleting `~/.nightcrow/sessions`, is then the only thing that ends a session. Whether repeating the login buys anything is a judgement about your own setup: on a loopback-bound session there may be nobody to re-authenticate against, while a viewer reachable from another machine is shell access that a stolen cookie opens. Two things to know either way: - -- **Lowering it reaches logins already handed out**, from the next restart — each one's deadline is brought down to the new lifetime. Raising it never pushes an existing deadline further away; only a fresh login gets the longer one. -- **The cookie asks for at most 400 days** whatever the setting says. That is the ceiling RFC 6265bis puts on `Max-Age`, and Chrome has enforced it since version 104, so asking for more would be silently reduced there and honoured elsewhere. A session with no expiry stays valid on the server past that — it is the browser that will have forgotten the cookie, so you log in again. - -`[web_viewer]` is not re-read by a config reload — the listener is already bound — so a change here takes effect when the session restarts. - -> **Security.** The viewer serves repository contents *and* interactive terminals, so an authenticated session is equivalent to shell access. It binds to loopback (`127.0.0.1`) by default and speaks plain HTTP with **no built-in TLS**. For remote access, do **not** expose the port directly — tunnel it over SSH (`ssh -L 8091:127.0.0.1:8091 host`) or put it behind a TLS reverse proxy. - -## Developing the frontend - -The UI lives in `viewer-ui/` (React + Vite + Tailwind). Its build output is committed to `viewer-ui/dist/` and embedded into the binary, so installing nightcrow never requires Node. - -```bash -npm --prefix viewer-ui install -npm --prefix viewer-ui run dev # Vite on :5173, proxying the API to :8091 -npm --prefix viewer-ui run build # rebuild dist/ — commit the result -``` - -CI rebuilds the bundle and fails if it differs from what is committed. - -**A tab open across a rebuild is told so.** Every reply to the poll the page already makes names the build it was served with, so within a few seconds of a rebuild the tab raises a notice with a **Reload** button and keeps it up until you act on it. Nothing reloads itself: a tab that did would take away whatever was being typed into a terminal, and being one build behind is not urgent enough to interrupt anyone. - -**Until you do, the tab is still running the bundle it loaded.** Chunk names carry a content hash, so a build replaces them rather than overwriting them, and the markdown renderer, the HTML preview, and the terminal panel are each fetched only when first needed — so one you open after the rebuild is simply gone. That pane then says part of the app could not be loaded and offers the same reload. (The same message covers a server that has become unreachable, since the browser reports both the same way — if the reload fails too, that is which one it was.) - -**What counts as a rebuild depends on the server.** A debug server reads `dist` from disk, so `npm --prefix viewer-ui run build` is the whole of it — reload the tab and you are current. A release binary carries the bundle inside it and a running process keeps the one it started with, so [an update](getting-started.md#updating) changes nothing until the session is restarted; that is the heavier move, since stopping the session ends its terminals, and it is why the notice can only appear afterwards. Reloading the tab never costs you anything — the same repositories, the same terminals, and the pane you were typing in. - -Design notes: [Architecture → Web layer](architecture/web.md). +The React/Vite source is in `viewer-ui/`; the committed `viewer-ui/dist/` bundle is embedded in release builds. Install Node.js 22 dependencies with `npm --prefix viewer-ui ci`, run `npm --prefix viewer-ui run dev` for a local frontend, and use the verification commands in [Getting started → Building and testing](getting-started.md#building-and-testing). diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 2eec94cf..7a6abef0 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -1,16 +1,15 @@ # Plugin crates -이 문서는 `plugins/` 아래 독립적으로 빌드되는 plugin crate에 적용한다. 파일 크기, 플랫폼, 테스트 배치, 영어 주석 같은 공통 규칙은 [루트 AGENTS.md](../AGENTS.md), [guardrails.md](../.agents/rules/guardrails.md), [testing.md](../.agents/rules/testing.md)를 따르고, plugin 계약의 기준은 [Plugins](../docs/plugins.md)와 [Plugin Host](../docs/architecture/plugin-host.md)다. +이 문서는 `plugins/` 아래 독립적으로 빌드되는 plugin crate에 적용한다. 저장소 공통 규칙은 [루트 AGENTS.md](../AGENTS.md)를 따르고, plugin 계약의 기준은 [Plugins](../docs/plugins.md)와 [Plugin Host](../docs/architecture/plugin-host.md)다. ## Host 경계 - Plugin은 host 주소 공간에 들어가는 library가 아니라 별도 실행 프로세스다. host 내부 모듈이나 Rust ABI에 의존하지 말고, stdin/stdout의 NDJSON과 명시적 protocol version으로만 통신한다. 와이어 형태를 호환되지 않게 바꾸면 양쪽 계약을 함께 갱신하고 version mismatch를 추측으로 복구하지 않는다. - Plugin 인스턴스는 저장소별로 실행되지만 전역 singleton이 아니다. host가 주입한 runtime directory를 사용해 plugin과 pane helper가 같은 소켓을 찾게 하며, cwd나 고정 전역 socket 경로로 다른 repository 인스턴스와 섞지 않는다. -- Pane token은 상관관계 키이지 인증 수단이 아니다. pane을 열거하거나 cwd로 대상을 추측하지 말고, helper가 제시한 token에 대한 `WatchPane` 채택과 모든 입력·relaunch 권한은 host의 guard 판단에 맡긴다. generation이 붙은 명령은 현재 spawn에만 적용한다. +- Pane token은 상관관계 키이지 인증 수단이 아니다. pane을 열거하거나 cwd로 대상을 추측하지 말고, helper가 제시한 token에 대한 `WatchPane` 채택과 모든 pane-scoped command(입력·relaunch·status·attention)는 host의 guard 판단에 맡긴다. generation이 붙은 명령은 현재 spawn에만 적용한다. - Adapter가 내놓는 입력·relaunch 계획은 제안일 뿐이다. provider 한도를 우회하거나 권한 인자를 임의로 추가하지 않으며, 사용자 설정의 허용 목록과 host의 생존·idle·generation·launch-command 검증을 전제로 한다. 손으로 provider를 시작한 pane은 기다리거나 입력할 수 있어도 재실행하지 않는다. ## 실패 격리와 provider 경계 -- Provider의 hook/statusline처럼 임계 경로에서 호출되는 helper는 입력 크기와 대기 시간을 제한하고 state machine이 읽는 필드만 whitelist한다. IPC나 plugin이 없어도 provider의 명령이 멈추거나 실패 메시지를 덮어쓰지 않도록 best-effort 전송과 안전한 fallback을 유지한다. -- Provider별 감지·세션 식별자·resume 인자는 plugin 안에만 둔다. 정확한 reset 시각이 있으면 한 번의 bounded wait로 처리하고, 없으면 bounded backoff로 격하한다. statusline usage 데이터는 deadline 관측에만 쓰며 한도 선언을 대신하지 않고, provider가 자체 retry 중인 동안에는 개입하지 않는다. -- 사용자 소유 설정을 수정하는 integration은 알 수 없는 JSON 키와 hook을 보존하고, 쓰기 전에 백업하며 원자적으로 교체한다. 제거 시 plugin이 식별할 수 있는 자기 항목만 제거하고, 대체한 statusline은 원본 입력 바이트를 그대로 전달해 chaining하며 `null`을 실행할 명령 없음으로 처리한다. +- Provider별 감지·세션 식별자·resume 인자는 plugin 안에만 둔다. 정확한 reset 시각이 있으면 한 번의 bounded wait로 처리하고, 없으면 bounded backoff로 격하하며 provider가 자체 retry 중인 동안에는 개입하지 않는다. +- Bundled recovery는 host가 전달한 launch command에서 provider를 식별한다. `watch_on_signal`과 `WatchPane`은 외부 plugin도 쓰는 공개 host 계약이므로 유지하되 bundled recovery가 별도 token adoption 경로를 갖는다고 가정하지 않는다. diff --git a/plugins/nightcrow-recovery/Cargo.toml b/plugins/nightcrow-recovery/Cargo.toml index 21d46bcc..37f60733 100644 --- a/plugins/nightcrow-recovery/Cargo.toml +++ b/plugins/nightcrow-recovery/Cargo.toml @@ -11,13 +11,8 @@ publish = false # reads lines, parses JSON and waits on a clock, so an async runtime or an HTTP # client would be weight with no purpose. anyhow = "1" -clap = { version = "4", features = ["derive"] } -dirs = "6" serde = { version = "1", features = ["derive"] } serde_json = "1" -[target.'cfg(windows)'.dependencies] -uds_windows = "1" - [dev-dependencies] tempfile = "3" diff --git a/plugins/nightcrow-recovery/src/helper.rs b/plugins/nightcrow-recovery/src/helper.rs deleted file mode 100644 index 60cf16c6..00000000 --- a/plugins/nightcrow-recovery/src/helper.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! The two modes a provider CLI invokes, not a human. -//! -//! Both run inside a child of the provider's own process, on that process's -//! critical path. So both do the least possible work — read one JSON object, -//! forward a handful of whitelisted fields, exit — and neither ever reports a -//! failure to its caller: a recovery plugin that is not running must look -//! exactly like one that was never installed. -//! -//! Whitelisting is the privacy boundary: only the fields the state machine -//! actually reads cross the socket, so nothing else can be logged, buffered, -//! or written down later. The one thing that leaves this process whole is the -//! statusline payload handed to the command we displaced (see -//! [`status_line`]) — the same bytes Claude Code was already giving it. We -//! narrow what we keep; we do not narrow what someone else was already told. - -use crate::ipc::{IpcMessage, send, socket_path}; -use crate::protocol::PANE_TOKEN_ENV; -use crate::provider::SignalKind; -use serde_json::{Map, Value}; -use std::io::Read; -use std::process::ExitCode; -use std::time::Duration; - -#[path = "helper_statusline.rs"] -mod status_line; - -/// Most stdin a helper will read. -/// -/// A hook payload is a handful of short strings plus an error message; 64 KiB is -/// far past any of that and keeps a provider that streams into us from making -/// this process grow. -const MAX_HELPER_STDIN_BYTES: u64 = 64 * 1024; - -/// Fields of a `StopFailure` payload the state machine reads. Everything else — -/// `error_message`, `transcript_path`, `prompt_id`, `cwd` — stays in the -/// provider's process. -const STOP_FAILURE_FIELDS: [&str; 3] = ["session_id", "error_type", "hook_event_name"]; - -/// The only statusline field this plugin wants. -const RATE_LIMITS_FIELD: &str = "rate_limits"; - -/// Forward a `StopFailure` payload. Always succeeds from the caller's point of -/// view; `StopFailure` ignores our exit code anyway, and a hook that fails -/// loudly would be worse than one that does nothing. -pub fn hook() -> ExitCode { - if let Some((token, payload)) = parse_object(&read_stdin_bytes()).and_then(|body| { - let token = pane_token()?; - Some((token, pick(&body, &STOP_FAILURE_FIELDS))) - }) { - let _ = send( - &socket_path().unwrap_or_default(), - &IpcMessage { - token, - kind: SignalKind::StopFailure, - payload: Value::Object(payload), - }, - ); - } - ExitCode::SUCCESS -} - -/// Report that a turn ended, so the host can raise the pane's attention marker. -/// -/// Sends no payload: `Stop` fires whatever the outcome, and which outcome it -/// was is not something the marker distinguishes. Reading stdin is still -/// necessary — a helper that never drained it would leave the provider -/// writing into a full pipe. -pub fn turn_end() -> ExitCode { - let _ = read_stdin_bytes(); - if let Some(token) = pane_token() { - let _ = send( - &socket_path().unwrap_or_default(), - &IpcMessage { - token, - kind: SignalKind::TurnEnd, - payload: Value::Null, - }, - ); - } - ExitCode::SUCCESS -} - -/// Forward the statusline's `rate_limits` and print a line — the line the user's -/// own statusline command printed, whenever installing this plugin displaced one. -/// `statusLine` holds a single command, so the only way not to cost the user -/// their statusline is to run it from ours; see [`status_line`]. -pub fn statusline() -> ExitCode { - let raw = read_stdin_bytes(); - let displaced = status_line::displaced(); - let refresh = refresh(&raw, displaced.as_ref(), status_line::BUDGET); - if let (Some(token), Some(limits)) = (pane_token(), refresh.rate_limits) { - let _ = send( - &socket_path().unwrap_or_default(), - &IpcMessage { - token, - kind: SignalKind::RateLimits, - payload: Value::Object(limits), - }, - ); - } - println!("{}", refresh.line); - ExitCode::SUCCESS -} - -/// What one statusline refresh comes to: the usage numbers to forward, and the -/// line to print. -struct Refresh { - rate_limits: Option>, - line: String, -} - -/// Decide both halves of a refresh without touching the socket or stdout, so -/// every way a displaced command can disappoint us stays testable — and so -/// the usage numbers are read out of the payload before anything is -/// delegated, which is what keeps a misbehaving statusline command from -/// costing us them. -fn refresh(raw: &[u8], displaced: Option<&Value>, budget: Duration) -> Refresh { - let rate_limits = parse_object(raw).and_then(rate_limits_of); - let line = status_line::line(displaced, raw, rate_limits.as_ref(), budget); - Refresh { rate_limits, line } -} - -/// The usage windows of a statusline payload, when it reported any. -fn rate_limits_of(body: Map) -> Option> { - body.get(RATE_LIMITS_FIELD)?.as_object().cloned() -} - -/// The pane this helper belongs to, from the environment its provider inherited. -/// Absent means this provider was not started by nightcrow, so there is nothing -/// to correlate and nothing to send. -fn pane_token() -> Option { - std::env::var(PANE_TOKEN_ENV) - .ok() - .filter(|t| !t.trim().is_empty()) -} - -/// Every byte the provider wrote, kept exactly as it wrote them. The statusline -/// helper hands these on to the command it displaced, and a re-serialised copy -/// is not the same thing: key order and number formatting are the provider's to -/// choose. A read that fails part-way keeps what did arrive — unparseable, -/// and treated as such below. -fn read_stdin_bytes() -> Vec { - let mut raw = Vec::new(); - let _ = std::io::stdin() - .take(MAX_HELPER_STDIN_BYTES) - .read_to_end(&mut raw); - raw -} - -/// The payload as an object, when that is what it is. Anything else is not an -/// error here: there is simply nothing of ours to forward out of it. -fn parse_object(raw: &[u8]) -> Option> { - match serde_json::from_slice::(raw) { - Ok(Value::Object(map)) => Some(map), - _ => None, - } -} - -/// Copy only the named string fields. A field that is present but not a string -/// is dropped rather than coerced. -fn pick(body: &Map, fields: &[&str]) -> Map { - let mut out = Map::new(); - for field in fields { - if let Some(value) = body.get(*field).filter(|v| v.is_string()) { - out.insert((*field).to_string(), value.clone()); - } - } - out -} - -#[cfg(test)] -#[path = "helper_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/helper_delegate.rs b/plugins/nightcrow-recovery/src/helper_delegate.rs deleted file mode 100644 index 3af09cff..00000000 --- a/plugins/nightcrow-recovery/src/helper_delegate.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Running a statusline command that is not ours, on a budget. -//! -//! Split out of `helper_statusline.rs` so that file decides *which* line gets -//! printed and this one is the process plumbing under it. Written for a caller -//! that must not be made to wait or to fail: the child is bounded, killed when -//! it overruns, reaped on every path, and any disappointment comes back as `None`. - -use std::io::{Read, Write}; -use std::process::{Child, Command, Stdio}; -use std::sync::mpsc; -use std::time::{Duration, Instant}; - -/// A POSIX shell, resolved on `PATH`. Not `$SHELL`: an interactive shell would -/// read the user's rc files on every single refresh. -/// -/// Windows included, and deliberately so: Claude Code runs a `statusLine` -/// through a POSIX shell on every platform, so the line being run here is one -/// `cmd.exe` would not run at all — the user would silently lose their -/// statusline. Which shell the *host's panes* use is a separate setting. -const SHELL: &str = "sh"; -const SHELL_COMMAND_ARG: &str = "-c"; - -/// Used only when `sh` cannot be spawned at all, which on Windows means no Git -/// Bash on `PATH`. A command written for `cmd.exe` is the only kind that can -/// work there, so it is worth one attempt before giving the caller nothing. -#[cfg(windows)] -const FALLBACK_SHELL: &str = "cmd.exe"; -#[cfg(windows)] -const FALLBACK_SHELL_COMMAND_ARG: &str = "/C"; - -/// Most stdout to take from a displaced command. A statusline is one short line; -/// this only stops a runaway script from growing this process. -const MAX_DELEGATED_STDOUT_BYTES: u64 = 64 * 1024; - -/// How often to look for a child that has closed its stdout but not yet exited. -const EXIT_POLL: Duration = Duration::from_millis(2); - -/// Run `command` with `raw` on its stdin and bring back what it printed, or -/// `None` if it could not be started, did not end well, or overran `budget`. -/// -/// Through the platform shell's command mode (`sh -c` or `cmd.exe /C`), not an -/// argv we split ourselves: the provider documents that a `statusLine` command -/// "runs in a shell", and its examples (`~` paths, `jq` pipelines, inline -/// `$(...)`) rely on it. Re-splitting the string would quietly change what it -/// means — it is the user's own configuration, not ours to reinterpret. -pub(super) fn capture(command: &str, raw: &[u8], budget: Duration) -> Option { - let deadline = Instant::now() + budget; - let mut child = spawn_shell(command)?; - - // Each pipe gets its own thread. Writing the payload first would deadlock - // against a command that answers before draining its input, and reading first - // would wedge a command that is still waiting for the rest of its input. - if let Some(mut stdin) = child.stdin.take() { - let payload = raw.to_vec(); - std::thread::spawn(move || { - // A command that ignores its input closes the pipe early. That is a - // choice it is allowed to make, not a failure of ours. - let _ = stdin.write_all(&payload); - }); - } - let Some(stdout) = child.stdout.take() else { - return abandon(child); - }; - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let mut captured = Vec::new(); - let _ = stdout - .take(MAX_DELEGATED_STDOUT_BYTES) - .read_to_end(&mut captured); - let _ = tx.send(captured); - }); - - let Ok(captured) = rx.recv_timeout(remaining(deadline)) else { - return abandon(child); - }; - if !exited_well(&mut child, deadline) { - return abandon(child); - } - // Output we cannot decode is output we cannot print; the caller has a line of - // its own for that. - let text = String::from_utf8(captured).ok()?; - // The child owns the content of the line; we own its framing, and the caller - // is the one that ends it with a newline. A command that printed nothing but - // whitespace did not render a statusline at all. - let printed = text.trim_end_matches(['\r', '\n']); - if printed.trim().is_empty() { - return None; - } - Some(printed.to_string()) -} - -/// Start `command` under a shell, with the pipes the caller needs. -/// -/// stderr is discarded: Claude Code reads our stdout for the statusline, and a -/// chatty script's stderr shares the terminal with it, so a warning meant for a -/// log must not end up rendered as the line. -fn spawn_shell(command: &str) -> Option { - match shell_child(SHELL, SHELL_COMMAND_ARG, command) { - Ok(child) => Some(child), - // Only a missing shell is worth a second try. A command that starts and - // then fails is a command that ran, and running it again under a shell - // it was not written for would just fail differently. - #[cfg(windows)] - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - shell_child(FALLBACK_SHELL, FALLBACK_SHELL_COMMAND_ARG, command).ok() - } - Err(_) => None, - } -} - -fn shell_child(shell: &str, arg: &str, command: &str) -> std::io::Result { - Command::new(shell) - .arg(arg) - .arg(command) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() -} - -/// Whether the child finished, and finished happily, before `deadline`. -/// -/// Polled rather than waited on: `wait` has no timeout, and a command that -/// closes its stdout and then sleeps must not get to hold a refresh open. -fn exited_well(child: &mut Child, deadline: Instant) -> bool { - loop { - match child.try_wait() { - Ok(Some(status)) => return status.success(), - Ok(None) if Instant::now() < deadline => std::thread::sleep(EXIT_POLL), - _ => return false, - } - } -} - -/// A child we are done waiting for. Killed so a wedged statusline command does not -/// outlive the refresh that started it, and reaped so it does not sit as a zombie -/// for whatever is left of this process's life. -fn abandon(mut child: Child) -> Option { - let _ = child.kill(); - let _ = child.wait(); - None -} - -fn remaining(deadline: Instant) -> Duration { - deadline.saturating_duration_since(Instant::now()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn capture_executes_the_displaced_command_through_a_shell() { - assert_eq!( - capture("echo delegated", b"{}", Duration::from_secs(5)).as_deref(), - Some("delegated") - ); - } - - /// The case that cost a user their statusline: a real one is POSIX shell, - /// and `cmd.exe` cannot run it on any platform. - #[test] - fn capture_runs_posix_shell_syntax() { - assert_eq!( - capture( - "value=$(echo hud); export COLUMNS=${COLUMNS:-80}; echo \"${value}\"", - b"{}", - Duration::from_secs(5) - ) - .as_deref(), - Some("hud") - ); - } - - #[test] - fn capture_hands_the_payload_to_the_command_on_stdin() { - assert_eq!( - capture("cat", b"from-claude", Duration::from_secs(5)).as_deref(), - Some("from-claude") - ); - } -} diff --git a/plugins/nightcrow-recovery/src/helper_statusline.rs b/plugins/nightcrow-recovery/src/helper_statusline.rs deleted file mode 100644 index dc16b7a8..00000000 --- a/plugins/nightcrow-recovery/src/helper_statusline.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! The one line Claude Code renders, and who gets to write it. -//! -//! Installing this plugin necessarily takes the user's statusline away: -//! `statusLine` in `settings.json` holds one command, not a list, so ours -//! replaces whatever was there. Chaining is the only way to give it back — -//! install recorded the value it displaced in a sidecar, and every refresh -//! runs that command with the very bytes Claude Code sent us and prints what -//! it printed. This plugin's own two-number line ([`render_statusline`]) -//! stands in only when there is nothing to chain to. Running the command -//! itself is [`delegate`]'s job. -//! -//! Nothing here fails upwards. A statusline that shows an error is worse than -//! a plain one, so every disappointment ends in the same place: our own line, -//! printed as if no chaining had been attempted. - -use crate::hooks::{SettingsPaths, displaced_statusline, is_ours}; -use serde_json::{Map, Value}; -use std::time::Duration; - -#[path = "helper_delegate.rs"] -mod delegate; - -/// Shown when the statusline payload carries no usage numbers — which is normal: -/// `rate_limits` is absent for accounts without a subscription window and before -/// the session's first response. -const STATUSLINE_FALLBACK: &str = "nightcrow: watching"; - -/// How long a displaced statusline command may take before we give up on it. -/// -/// Claude Code documents no timeout for a statusline and cancels an in-flight -/// script when the next update arrives, so the provider is already the one -/// deciding we took too long. This bound is for the other direction: a command -/// that never returns must not make this process immortal. Two seconds is -/// generous even for the `git`-shelling scripts the provider's own guidance -/// calls slow, and inside the five seconds this plugin asks Claude Code to -/// allow its hook — the most patience anything here claims of the provider. -pub(super) const BUDGET: Duration = Duration::from_secs(2); - -const TYPE_KEY: &str = "type"; -const COMMAND_KEY: &str = "command"; -/// The only `type` of `statusLine` entry there is anything for us to run. -const COMMAND_TYPE: &str = "command"; - -/// The `statusLine` install displaced, when there is one to chain to. Every way -/// there can be nothing — no `HOME` to look under, no sidecar because we displaced -/// nothing, a sidecar we cannot read — reads the same from here. -pub(super) fn displaced() -> Option { - displaced_statusline(&SettingsPaths::discover().ok()?) -} - -/// The line to print for this refresh: the displaced command's, when there is one -/// that can produce it, and ours otherwise. -pub(super) fn line( - displaced: Option<&Value>, - raw: &[u8], - rate_limits: Option<&Map>, - budget: Duration, -) -> String { - delegated(displaced, raw, budget).unwrap_or_else(|| render_statusline(rate_limits)) -} - -fn delegated(displaced: Option<&Value>, raw: &[u8], budget: Duration) -> Option { - let command = command_of(displaced?)?; - // Our own command in the sidecar would be a chain that runs this binary from - // itself, and again from there. `is_ours` is the same substring test install and - // uninstall recognise our entries by, so what is refused here is exactly what - // those two already consider ours. - if is_ours(command) { - return None; - } - delegate::capture(command, raw, budget) -} - -/// The command string inside whatever Claude Code allowed as a `statusLine`. -/// -/// Install recorded that value verbatim, so this reads the shape the provider -/// documents and the one this plugin itself writes — an object with `type` and -/// `command` — and also accepts a bare string, which is the obvious -/// hand-written form. A value with some other `type` is a statusline we do not -/// know how to run, and guessing at it is worse than standing in for it. -/// -/// The entry's other fields are Claude Code's to act on, not ours. -fn command_of(value: &Value) -> Option<&str> { - let command = match value { - Value::String(command) => command.as_str(), - Value::Object(map) => { - let declared = map.get(TYPE_KEY).and_then(Value::as_str); - if declared.is_some_and(|kind| kind != COMMAND_TYPE) { - return None; - } - map.get(COMMAND_KEY)?.as_str()? - } - _ => return None, - }; - let command = command.trim(); - (!command.is_empty()).then_some(command) -} - -/// A short line built only from fields whose meaning is documented: the usage -/// percentage of each window the provider reported. -fn render_statusline(rate_limits: Option<&Map>) -> String { - let Some(limits) = rate_limits else { - return STATUSLINE_FALLBACK.to_string(); - }; - let mut parts = Vec::new(); - for (label, key) in [("5h", "five_hour"), ("7d", "seven_day")] { - if let Some(used) = limits - .get(key) - .and_then(|w| w.get("used_percentage")) - .and_then(Value::as_f64) - { - parts.push(format!("{label} {}%", used.round() as i64)); - } - } - if parts.is_empty() { - return STATUSLINE_FALLBACK.to_string(); - } - parts.join(" | ") -} - -#[cfg(test)] -#[path = "helper_statusline_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/helper_statusline_tests.rs b/plugins/nightcrow-recovery/src/helper_statusline_tests.rs deleted file mode 100644 index 77803346..00000000 --- a/plugins/nightcrow-recovery/src/helper_statusline_tests.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Who writes the line, and what happens when the displaced command will not. -//! -//! These tests really do spawn shells, which is the point of them: how a -//! statusline command behaves towards us — printing, failing, hanging, shouting on -//! stderr — is only worth pinning if a real one gets to do it. - -use super::*; -#[cfg(unix)] -use std::time::Instant; - -/// A budget no test in here is meant to reach. Only the wedged case waits. -const ENOUGH: Duration = Duration::from_secs(5); - -/// The budget for a command that will never finish. Short enough not to be felt, -/// long enough that expiry is a decision rather than a lost race with `sh`. -#[cfg(unix)] -const BRIEF: Duration = Duration::from_millis(150); - -/// What `render_statusline` makes of [`limits`], so `OURS` in an assertion reads -/// as "the chain was declined or came to nothing". -const OURS: &str = "5h 40%"; - -/// The shape this plugin writes into `statusLine`, and so the shape it records -/// when it displaces one. -fn entry(command: &str) -> Value { - serde_json::json!({ "type": "command", "command": command, "padding": 2 }) -} - -fn limits() -> Map { - match serde_json::json!({"five_hour": {"used_percentage": 40.0}}) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - } -} - -/// What a refresh prints when install recorded `displaced`, with usage numbers -/// always there to fall back on. -fn rendered(displaced: &Value, raw: &[u8], budget: Duration) -> String { - line(Some(displaced), raw, Some(&limits()), budget) -} - -#[cfg(unix)] -#[test] -fn the_displaced_commands_own_line_becomes_our_line() { - let displaced = entry("echo 'hud | main | 12%'"); - - let printed = rendered(&displaced, b"{}", ENOUGH); - - assert_eq!(printed, "hud | main | 12%"); -} - -#[cfg(unix)] -#[test] -fn the_bytes_claude_code_sent_reach_the_displaced_command_unchanged() { - // Key order, number formatting and string escapes are all the provider's, and - // a parsed and re-encoded copy would keep none of the three. - let raw = br#"{"zeta":1,"alpha":2.50,"big":1e3,"who":"a\/b"}"#; - - let printed = rendered(&entry("cat"), raw, ENOUGH); - - assert_eq!(printed.as_bytes(), raw); -} - -#[cfg(unix)] -#[test] -fn a_displaced_statusline_recorded_as_a_bare_string_is_run_too() { - let displaced = Value::String("echo theirs".to_string()); - - assert_eq!(rendered(&displaced, b"{}", ENOUGH), "theirs"); -} - -#[cfg(unix)] -#[test] -fn a_multi_line_displaced_statusline_keeps_its_own_line_breaks() { - let displaced = entry("printf 'top\\nbottom\\n'"); - - assert_eq!(rendered(&displaced, b"{}", ENOUGH), "top\nbottom"); -} - -#[test] -fn with_nothing_displaced_our_own_line_is_printed() { - let numbers = limits(); - - assert_eq!(line(None, b"{}", Some(&numbers), ENOUGH), OURS); - // A JSON null is what install records when it displaced no statusline at all. - assert_eq!(rendered(&Value::Null, b"{}", ENOUGH), OURS); - assert_eq!(line(None, b"{}", None, ENOUGH), STATUSLINE_FALLBACK); -} - -#[cfg(unix)] -#[test] -fn a_displaced_command_that_fails_falls_back_however_much_it_printed() { - let displaced = entry("echo half-a-line; exit 3"); - - assert_eq!(rendered(&displaced, b"{}", ENOUGH), OURS); -} - -#[cfg(unix)] -#[test] -fn a_displaced_command_that_cannot_be_run_falls_back() { - let displaced = entry("/nonexistent/statusline-c0ffee --now"); - - assert_eq!(rendered(&displaced, b"{}", ENOUGH), OURS); -} - -#[cfg(unix)] -#[test] -fn a_displaced_command_that_prints_nothing_usable_falls_back() { - for silent in ["true", "printf '\\n\\n'", "printf ' '"] { - let printed = rendered(&entry(silent), b"{}", ENOUGH); - - assert_eq!(printed, OURS, "{silent}"); - } -} - -#[cfg(unix)] -#[test] -fn a_displaced_commands_stderr_never_reaches_the_statusline() { - let displaced = entry("echo noise >&2; echo theirs"); - - assert_eq!(rendered(&displaced, b"{}", ENOUGH), "theirs"); -} - -#[cfg(unix)] -#[test] -fn a_displaced_command_that_never_finishes_is_given_up_on_and_falls_back() { - let displaced = entry("sleep 30"); - let started = Instant::now(); - - let printed = rendered(&displaced, b"{}", BRIEF); - - assert_eq!(printed, OURS); - assert!( - started.elapsed() < Duration::from_secs(5), - "the refresh waited {:?} on a command it had given up on", - started.elapsed() - ); -} - -#[test] -fn our_own_command_is_never_run_from_our_own_statusline() { - // Both of these would print if they ran, and both carry the marker install - // and uninstall recognise us by, so neither may: a sidecar naming this binary - // would otherwise chain the statusline into itself. - for ours in [ - "echo nightcrow-recovery statusline", - "/opt/nightcrow/libexec/nightcrow-recovery statusline || echo theirs", - ] { - let printed = rendered(&entry(ours), b"{}", ENOUGH); - - assert_eq!(printed, OURS, "{ours}"); - } -} - -#[test] -fn a_displaced_value_we_cannot_execute_falls_back_rather_than_guessing() { - // The first is the case that matters: a future `type` may mean something - // entirely unlike a shell command, so its `command` is not ours to run. - for unrunnable in [ - serde_json::json!({"type": "some-future-kind", "command": "echo theirs"}), - serde_json::json!({"type": "command", "command": 42}), - serde_json::json!({"type": "command"}), - serde_json::json!({"command": " "}), - serde_json::json!([{"command": "echo theirs"}]), - serde_json::json!(7), - ] { - let printed = rendered(&unrunnable, b"{}", ENOUGH); - - assert_eq!(printed, OURS, "{unrunnable}"); - } -} - -#[test] -fn a_statusline_reports_the_usage_of_every_window_the_provider_gave() { - let limits = match serde_json::json!({ - "five_hour": {"used_percentage": 23.5, "resets_at": 1_767_225_600i64}, - "seven_day": {"used_percentage": 41.2, "resets_at": 1_767_657_600i64} - }) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - }; - assert_eq!(render_statusline(Some(&limits)), "5h 24% | 7d 41%"); -} - -#[test] -fn a_statusline_with_one_window_reports_only_that_window() { - let limits = match serde_json::json!({"seven_day": {"used_percentage": 8.0}}) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - }; - assert_eq!(render_statusline(Some(&limits)), "7d 8%"); -} - -#[test] -fn a_statusline_still_prints_a_line_when_the_provider_reported_no_windows() { - assert_eq!(render_statusline(None), STATUSLINE_FALLBACK); - assert_eq!(render_statusline(Some(&Map::new())), STATUSLINE_FALLBACK); - let unusable = match serde_json::json!({"five_hour": {"used_percentage": "lots"}}) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - }; - assert_eq!(render_statusline(Some(&unusable)), STATUSLINE_FALLBACK); -} diff --git a/plugins/nightcrow-recovery/src/helper_tests.rs b/plugins/nightcrow-recovery/src/helper_tests.rs deleted file mode 100644 index 2c630ceb..00000000 --- a/plugins/nightcrow-recovery/src/helper_tests.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! The whitelisting, and what one refresh decides. Reading stdin and connecting -//! to the socket are covered by `ipc_tests`; what matters here is that nothing -//! outside the whitelist can be forwarded, whatever a provider puts in its -//! payload, and that nothing a displaced statusline command does can lose the -//! usage numbers on the way. Which command gets to print is `helper_statusline`'s. - -use super::*; - -fn stop_failure_payload() -> Map { - match serde_json::json!({ - "session_id": "11111111-2222-3333-4444-555555555555", - "prompt_id": "p_1", - "transcript_path": "/home/x/.claude/projects/repo/session.jsonl", - "cwd": "/w/repo", - "hook_event_name": "StopFailure", - "error_type": "rate_limit", - "error_message": "You have exceeded your usage for account billing@example.com", - "agent_id": "a_1" - }) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - } -} - -#[test] -fn only_the_three_whitelisted_hook_fields_are_forwarded() { - let picked = pick(&stop_failure_payload(), &STOP_FAILURE_FIELDS); - let mut keys: Vec<&String> = picked.keys().collect(); - keys.sort(); - assert_eq!( - keys, - vec!["error_type", "hook_event_name", "session_id"], - "nothing else may leave the provider's process" - ); -} - -#[test] -fn a_transcript_path_and_an_error_message_are_never_forwarded() { - let picked = pick(&stop_failure_payload(), &STOP_FAILURE_FIELDS); - assert!(picked.get("transcript_path").is_none()); - assert!(picked.get("error_message").is_none()); - assert!(picked.get("cwd").is_none()); - assert!(picked.get("prompt_id").is_none()); - let serialised = Value::Object(picked).to_string(); - assert!(!serialised.contains("billing@example.com"), "{serialised}"); -} - -#[test] -fn a_whitelisted_field_of_the_wrong_type_is_dropped_rather_than_coerced() { - let payload = match serde_json::json!({ - "session_id": 42, - "error_type": null, - "hook_event_name": {"name": "StopFailure"} - }) { - Value::Object(map) => map, - _ => unreachable!("the fixture is an object"), - }; - assert!(pick(&payload, &STOP_FAILURE_FIELDS).is_empty()); -} - -#[test] -fn a_payload_missing_every_whitelisted_field_forwards_nothing() { - assert!(pick(&Map::new(), &STOP_FAILURE_FIELDS).is_empty()); -} - -/// A statusline payload with usage numbers in it, as the bytes a provider would -/// have written them. -const STATUSLINE_BODY: &[u8] = - br#"{"session_id":"s","rate_limits":{"five_hour":{"used_percentage":40.0}}}"#; - -/// Only the command that hangs spends this, and no assertion below turns on -/// whether the others managed to print in time. -const BUDGET: Duration = Duration::from_millis(200); - -/// A generous budget, for the cases that are about what a command printed. -#[cfg(unix)] -const ENOUGH: Duration = Duration::from_secs(5); - -fn statusline_entry(command: &str) -> Value { - serde_json::json!({ "type": "command", "command": command }) -} - -fn five_hour(refresh: &Refresh) -> Option<&Value> { - refresh.rate_limits.as_ref()?.get("five_hour") -} - -#[test] -fn a_refresh_forwards_the_usage_numbers_whatever_the_displaced_command_does() { - let expected = serde_json::json!({"used_percentage": 40.0}); - for displaced in [ - Value::Null, - statusline_entry("echo theirs"), - statusline_entry("exit 1"), - statusline_entry("sleep 30"), - statusline_entry("/x/nightcrow-recovery statusline"), - serde_json::json!({"type": "some-future-kind"}), - ] { - let refresh = refresh(STATUSLINE_BODY, Some(&displaced), BUDGET); - - let forwarded = five_hour(&refresh); - - assert_eq!(forwarded, Some(&expected), "lost them for {displaced}"); - } -} - -#[cfg(unix)] -#[test] -fn a_refresh_prints_the_displaced_statuslines_line_rather_than_our_own() { - let displaced = statusline_entry("echo theirs"); - - let refresh = refresh(STATUSLINE_BODY, Some(&displaced), ENOUGH); - - assert_eq!(refresh.line, "theirs"); -} - -#[test] -fn a_refresh_with_nothing_displaced_prints_the_numbers_it_forwarded() { - let refresh = refresh(STATUSLINE_BODY, None, BUDGET); - - assert_eq!(refresh.line, "5h 40%"); -} - -#[cfg(unix)] -#[test] -fn a_body_we_cannot_parse_still_reaches_the_displaced_command() { - // Parsing is for the fields we forward; the bytes are the displaced command's - // business, and a payload we cannot read may well be one it can. - let displaced = statusline_entry("cat"); - - let refresh = refresh(b"not json at all", Some(&displaced), ENOUGH); - - assert!(refresh.rate_limits.is_none()); - assert_eq!(refresh.line, "not json at all"); -} diff --git a/plugins/nightcrow-recovery/src/hooks.rs b/plugins/nightcrow-recovery/src/hooks.rs deleted file mode 100644 index 71800504..00000000 --- a/plugins/nightcrow-recovery/src/hooks.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Install and uninstall this plugin's entries in Claude Code's -//! `~/.claude/settings.json`. -//! -//! That file belongs to the user, not to us: it may hold keys and hook events we -//! know nothing about, so every edit here is a merge that preserves what we did -//! not put there, and a refusal when the file cannot be understood. The JSON -//! surgery itself lives in [`merge`]; this module is the filesystem around it. - -use anyhow::{Context, Result}; -use serde_json::{Map, Value, json}; -use std::fs; -use std::path::{Path, PathBuf}; - -#[path = "hooks_merge.rs"] -mod merge; - -use merge::{MARKER, STATUSLINE_KEY}; - -/// Re-exported for the statusline helper, which must recognise our own command in -/// order to refuse chaining to it — the same check install and uninstall use to -/// recognise our entries. -pub(crate) use merge::is_ours; - -const CLAUDE_DIR: &str = ".claude"; -const SETTINGS_FILE: &str = "settings.json"; -const BACKUP_FILE: &str = "settings.json.bak"; -/// Sidecar name: prefixed with the crate name because it lives in a directory -/// the provider owns, and must never look like something the provider wrote. -const SIDECAR_FILE: &str = "nightcrow-recovery.displaced.json"; - -/// `settings.json` is user configuration in the user's home directory: readable -/// and writable by its owner only. On Windows the default ACL on a user-home -/// file already suffices, so the mode is only applied on Unix. -const SETTINGS_MODE: u32 = 0o600; - -/// Restrict a path to its owner on platforms where that is meaningful. -#[cfg(unix)] -fn restrict_to_owner(path: &Path, mode: u32) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .map_err(|e| anyhow::anyhow!("cannot set mode on {}: {e}", path.display())) -} - -#[cfg(not(unix))] -fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<()> { - Ok(()) -} - -/// Where the Claude Code settings we edit live. A struct so tests can point at a -/// temp dir instead of the real home directory. -#[derive(Debug, Clone)] -pub struct SettingsPaths { - pub settings: PathBuf, - pub backup: PathBuf, - /// Sidecar holding what we displaced, so uninstall can put it back. - pub sidecar: PathBuf, -} - -impl SettingsPaths { - /// `~/.claude/settings.json` and its siblings. - pub fn from_home(home: &Path) -> Self { - let dir = home.join(CLAUDE_DIR); - Self { - settings: dir.join(SETTINGS_FILE), - backup: dir.join(BACKUP_FILE), - sidecar: dir.join(SIDECAR_FILE), - } - } - - /// Resolves the home directory, erroring when it cannot be determined. - pub fn discover() -> Result { - let home = - dirs::home_dir().context("no home directory, so ~/{CLAUDE_DIR} cannot be located")?; - Ok(Self::from_home(&home)) - } -} - -/// Install our `StopFailure` hook and statusline entries, merging into whatever -/// is already there. Returns one line per change, for printing. -pub fn install(paths: &SettingsPaths, exe: &str) -> Result> { - let command = merge::hook_command(exe); - anyhow::ensure!( - merge::is_ours(&command), - "hook command `{command}` does not contain `{MARKER}`, so uninstall could not \ - recognise it later; install through a binary whose path contains `{MARKER}`" - ); - - let mut settings = read_settings(&paths.settings)?; - let (mut changes, displaced) = merge::merge_into(&mut settings, exe) - .with_context(|| format!("cannot merge our entries into {}", paths.settings.display()))?; - if changes.is_empty() { - return Ok(vec![format!( - "{} already has our hook and statusline; nothing changed", - paths.settings.display() - )]); - } - - back_up(&paths.settings, &paths.backup)?; - if let Some(previous) = displaced { - let mut sidecar = Map::new(); - sidecar.insert(STATUSLINE_KEY.to_string(), previous); - write_json(&paths.sidecar, &Value::Object(sidecar))?; - changes.push(format!( - "recorded the displaced {STATUSLINE_KEY} in {}", - paths.sidecar.display() - )); - } - write_json(&paths.settings, &settings)?; - Ok(changes) -} - -/// Remove only what [`install`] added. Returns one line per change. -pub fn uninstall(paths: &SettingsPaths) -> Result> { - if !paths.settings.exists() { - return Ok(vec![format!( - "{} does not exist; nothing to remove", - paths.settings.display() - )]); - } - let mut settings = read_settings(&paths.settings)?; - let restore = read_sidecar(&paths.sidecar); - let changes = merge::strip_from(&mut settings, restore).with_context(|| { - format!( - "cannot remove our entries from {}", - paths.settings.display() - ) - })?; - if changes.is_empty() { - return Ok(vec![format!( - "{} has none of our entries; nothing to remove", - paths.settings.display() - )]); - } - - write_json(&paths.settings, &settings)?; - if paths.sidecar.exists() { - fs::remove_file(&paths.sidecar) - .with_context(|| format!("cannot delete {}", paths.sidecar.display()))?; - } - Ok(changes) -} - -/// The `statusLine` install displaced, for the statusline helper to chain to. -/// -/// Read on every refresh, which is why it stays this cheap: one small file, and -/// any trouble reading it means no chain rather than a failure. The value can be -/// JSON `null` — that is what install records when it found no `statusLine` to -/// displace — so a caller must decide what a null means to it. -pub fn displaced_statusline(paths: &SettingsPaths) -> Option { - read_sidecar(&paths.sidecar) -} - -/// Absent or empty means "no settings yet"; anything that is not a JSON object -/// is a file we do not understand, and guessing at it is worse than stopping. -fn read_settings(path: &Path) -> Result { - let text = match fs::read_to_string(path) { - Ok(text) => text, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})), - Err(e) => return Err(anyhow::anyhow!("cannot read {}: {e}", path.display())), - }; - if text.trim().is_empty() { - return Ok(json!({})); - } - let value: Value = serde_json::from_str(&text).map_err(|e| { - anyhow::anyhow!( - "{} is not valid JSON ({e}); refusing to edit a file we cannot parse", - path.display() - ) - })?; - anyhow::ensure!( - value.is_object(), - "{} holds a JSON {} at its top level, not an object; refusing to edit it", - path.display(), - merge::kind_of(&value) - ); - Ok(value) -} - -/// The recorded `statusLine`, or `None` when there is no usable sidecar. A -/// damaged sidecar is not fatal: the worst outcome is that we drop a key the user -/// can set again, whereas failing would leave our entries installed for good. -fn read_sidecar(path: &Path) -> Option { - let text = fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&text).ok()?; - value.get(STATUSLINE_KEY).cloned() -} - -/// Copy the current settings verbatim before the first write, so a bad merge is -/// recoverable. Nothing to copy when the file does not exist yet, and in that -/// case an older backup from a previous install is left as it is. -fn back_up(settings: &Path, backup: &Path) -> Result<()> { - if !settings.exists() { - return Ok(()); - } - fs::copy(settings, backup).map_err(|e| { - anyhow::anyhow!( - "cannot copy {} to {}: {e}", - settings.display(), - backup.display() - ) - })?; - Ok(()) -} - -/// Write via a temp file in the same directory and rename over the target, so a -/// crash mid-write cannot leave the provider with a half-written settings file. -fn write_json(path: &Path, value: &Value) -> Result<()> { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir) - .map_err(|e| anyhow::anyhow!("cannot create {}: {e}", dir.display()))?; - } - let mut text = serde_json::to_string_pretty(value) - .map_err(|e| anyhow::anyhow!("cannot encode JSON for {}: {e}", path.display()))?; - text.push('\n'); - - let temp = path.with_extension("json.tmp"); - fs::write(&temp, &text).map_err(|e| anyhow::anyhow!("cannot write {}: {e}", temp.display()))?; - // Mode is set before the rename, so the target is never briefly world-readable. - // On Windows the default ACL on a user-home file already suffices. - restrict_to_owner(&temp, SETTINGS_MODE)?; - fs::rename(&temp, path).map_err(|e| { - anyhow::anyhow!( - "cannot rename {} to {}: {e}", - temp.display(), - path.display() - ) - })?; - Ok(()) -} - -#[cfg(test)] -#[path = "hooks_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/hooks_merge.rs b/plugins/nightcrow-recovery/src/hooks_merge.rs deleted file mode 100644 index 0d78e3b2..00000000 --- a/plugins/nightcrow-recovery/src/hooks_merge.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! The JSON surgery behind `install`/`uninstall`, as pure functions over -//! [`Value`] so the tricky cases are testable without touching a filesystem. -//! -//! Our entries are identified by [`MARKER`] appearing as a substring of a -//! `command` string. `command` is the only field Claude Code's hook and -//! statusline schema lets us write free text into; a custom key of our own (say -//! `"nightcrowOwned": true`) may be rejected or warned about as unknown by the -//! provider, so it is not a safe place to keep our bookkeeping. - -use anyhow::Result; -use serde_json::{Map, Value, json}; - -/// Substring that marks a settings entry as ours. It is the crate name, which is -/// also the installed binary's name, so `" hook"` carries it for free. -pub(crate) const MARKER: &str = "nightcrow-recovery"; - -pub(crate) const HOOKS_KEY: &str = "hooks"; -pub(crate) const HOOK_EVENT: &str = "StopFailure"; -/// Fires as every turn ends, whatever the outcome. Where `StopFailure` is -/// narrowed to one `error_type`, this one cannot be: "the turn is over" has no -/// sub-kinds to ask for, and the marker it raises means only that. -pub(crate) const TURN_END_EVENT: &str = "Stop"; -/// `Stop` carries no error to match on, so the group is the unfiltered one. -pub(crate) const TURN_END_MATCHER: &str = ""; -pub(crate) const STATUSLINE_KEY: &str = "statusLine"; -const MATCHER_KEY: &str = "matcher"; -const COMMAND_KEY: &str = "command"; - -/// The one `error_type` we ask to be woken for. Least privilege: a rate limit is -/// the only failure this plugin acts on, so payloads for unrelated failures -/// (`authentication_failed`, `billing_error`, ...) never reach this process at -/// all. The consequence is deliberate: transient `overloaded`/`server_error` -/// conditions are recognised from the pane's terminal output instead of here. -pub(crate) const HOOK_MATCHER: &str = "rate_limit"; - -/// Seconds Claude Code should wait for our hook. The hook only parses one JSON -/// payload and hands it to the running host, so this is already generous; the cap -/// matters because a wedged helper must not sit in the provider's path, and -/// `StopFailure` ignores our output and exit code anyway. -const HOOK_TIMEOUT_SECS: u64 = 5; - -/// Columns of gutter around the rendered statusline, so it does not butt against -/// the terminal edge. Matches the value in Claude Code's own documented example. -const STATUSLINE_PADDING: u64 = 2; - -/// Quote a path for the POSIX shell these commands are run in. -/// -/// Claude Code runs a hook and a `statusLine` through a shell on every -/// platform, and that shell reads each backslash of a Windows path as an -/// escape — `C:\Users\me\plugin` arrives as `C:Usersmeplugin` and is simply -/// not found. Single quotes suspend every interpretation the shell would -/// otherwise make, spaces included; an embedded one is closed, escaped on its -/// own, and reopened. -fn shell_quoted(path: &str) -> String { - format!("'{}'", path.replace('\'', r"'\''")) -} - -pub(crate) fn hook_command(exe: &str) -> String { - format!("{} hook", shell_quoted(exe)) -} - -pub(crate) fn turn_end_command(exe: &str) -> String { - format!("{} turn-end", shell_quoted(exe)) -} - -pub(crate) fn statusline_command(exe: &str) -> String { - format!("{} statusline", shell_quoted(exe)) -} - -pub(crate) fn is_ours(command: &str) -> bool { - command.contains(MARKER) -} - -fn command_of(entry: &Value) -> Option<&str> { - entry.get(COMMAND_KEY).and_then(Value::as_str) -} - -fn hook_entry(command: &str) -> Value { - json!({ "type": "command", "command": command, "timeout": HOOK_TIMEOUT_SECS }) -} - -fn statusline_entry(command: &str) -> Value { - json!({ "type": "command", "command": command, "padding": STATUSLINE_PADDING }) -} - -fn matcher_group(matcher: &str) -> Value { - let mut group = Map::new(); - group.insert(MATCHER_KEY.to_string(), Value::String(matcher.to_string())); - group.insert(HOOKS_KEY.to_string(), Value::Array(Vec::new())); - Value::Object(group) -} - -pub(crate) fn kind_of(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - -fn object_mut<'a>(value: &'a mut Value, what: &str) -> Result<&'a mut Map> { - let found = kind_of(value); - value - .as_object_mut() - .ok_or_else(|| anyhow::anyhow!("{what} is a JSON {found}, not an object")) -} - -fn array_mut<'a>(value: &'a mut Value, what: &str) -> Result<&'a mut Vec> { - let found = kind_of(value); - value - .as_array_mut() - .ok_or_else(|| anyhow::anyhow!("{what} is a JSON {found}, not an array")) -} - -/// Add our hook entry and statusline to `settings`, leaving every other key and -/// every array entry we did not add exactly as it was. Returns one line per -/// change plus the `statusLine` value we displaced, which the caller must record -/// before writing so uninstall can put it back. -pub(crate) fn merge_into(settings: &mut Value, exe: &str) -> Result<(Vec, Option)> { - let mut changes = Vec::new(); - let root = object_mut(settings, "the settings root")?; - - merge_hook( - root, - HOOK_EVENT, - HOOK_MATCHER, - &hook_command(exe), - &mut changes, - )?; - merge_hook( - root, - TURN_END_EVENT, - TURN_END_MATCHER, - &turn_end_command(exe), - &mut changes, - )?; - - let mut displaced = None; - let ours = root - .get(STATUSLINE_KEY) - .and_then(command_of) - .is_some_and(is_ours); - if !ours { - let previous = root.get(STATUSLINE_KEY).cloned().unwrap_or(Value::Null); - let command = statusline_command(exe); - root.insert(STATUSLINE_KEY.to_string(), statusline_entry(&command)); - changes.push(if previous.is_null() { - format!("set {STATUSLINE_KEY}: {command}") - } else { - format!("replaced {STATUSLINE_KEY} with: {command} (previous value recorded)") - }); - displaced = Some(previous); - } - - Ok((changes, displaced)) -} - -/// Put one command into one hook event's matcher group, creating whatever is -/// missing and touching nothing else. -fn merge_hook( - root: &mut Map, - event: &str, - matcher: &str, - command: &str, - changes: &mut Vec, -) -> Result<()> { - let hooks = root.entry(HOOKS_KEY).or_insert_with(|| json!({})); - let hooks = object_mut(hooks, "`hooks`")?; - let groups = hooks.entry(event).or_insert_with(|| json!([])); - let groups = array_mut(groups, &format!("`hooks.{event}`"))?; - let index = match groups - .iter() - .position(|g| g.get(MATCHER_KEY).and_then(Value::as_str) == Some(matcher)) - { - Some(index) => index, - None => { - groups.push(matcher_group(matcher)); - groups.len() - 1 - } - }; - let group = object_mut(&mut groups[index], "the matcher group")?; - let entries = group.entry(HOOKS_KEY).or_insert_with(|| json!([])); - let entries = array_mut(entries, "the matcher group's `hooks`")?; - // An exact command match already present is what makes a second install a - // no-op instead of a duplicate entry. - if entries.iter().any(|e| command_of(e) == Some(command)) { - return Ok(()); - } - entries.push(hook_entry(command)); - changes.push(format!( - "added {event} hook for matcher `{matcher}`: {command}" - )); - Ok(()) -} - -/// Remove exactly what [`merge_into`] added, putting `restore` back as -/// `statusLine` when it holds a value we recorded. Containers we empty are -/// collapsed so the file returns to its original shape. -pub(crate) fn strip_from(settings: &mut Value, restore: Option) -> Result> { - let mut changes = Vec::new(); - let root = object_mut(settings, "the settings root")?; - - let removed = strip_hooks(root); - if removed > 0 { - changes.push(format!("removed {removed} hook command(s)")); - } - - let ours = root - .get(STATUSLINE_KEY) - .and_then(command_of) - .is_some_and(is_ours); - if ours { - match restore { - Some(previous) if !previous.is_null() => { - root.insert(STATUSLINE_KEY.to_string(), previous); - changes.push(format!("restored the previous {STATUSLINE_KEY}")); - } - _ => { - root.remove(STATUSLINE_KEY); - changes.push(format!("removed our {STATUSLINE_KEY}")); - } - } - } - - Ok(changes) -} - -/// Drop our hook commands and return how many went. A `hooks` subtree of an -/// unexpected shape is left alone rather than rewritten: it cannot be ours. -fn strip_hooks(root: &mut Map) -> usize { - let mut removed = 0usize; - let mut hooks_empty = false; - if let Some(hooks) = root.get_mut(HOOKS_KEY).and_then(Value::as_object_mut) { - for event in [HOOK_EVENT, TURN_END_EVENT] { - removed += strip_hook_event(hooks, event); - } - hooks_empty = hooks.is_empty(); - } - if hooks_empty && removed > 0 { - root.remove(HOOKS_KEY); - } - removed -} - -/// Drop our commands from one hook event, collapsing only what we emptied. -fn strip_hook_event(hooks: &mut Map, event: &str) -> usize { - let mut removed = 0usize; - let mut groups_empty = false; - if let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) { - let mut drop_indexes = Vec::new(); - for (index, group) in groups.iter_mut().enumerate() { - let Some(entries) = group.get_mut(HOOKS_KEY).and_then(Value::as_array_mut) else { - continue; - }; - let before = entries.len(); - entries.retain(|e| !command_of(e).is_some_and(is_ours)); - if entries.len() == before { - continue; - } - removed += before - entries.len(); - if entries.is_empty() { - drop_indexes.push(index); - } - } - for index in drop_indexes.into_iter().rev() { - groups.remove(index); - } - groups_empty = groups.is_empty(); - } - // Only collapse containers we emptied ourselves, so a user's own empty - // event list survives an uninstall that found nothing of ours. - if groups_empty && removed > 0 { - hooks.remove(event); - } - removed -} - -#[cfg(test)] -#[path = "hooks_merge_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/hooks_merge_tests.rs b/plugins/nightcrow-recovery/src/hooks_merge_tests.rs deleted file mode 100644 index eec06f0a..00000000 --- a/plugins/nightcrow-recovery/src/hooks_merge_tests.rs +++ /dev/null @@ -1,240 +0,0 @@ -use super::*; - -/// A path containing [`MARKER`], as the installed binary's path does. -const EXE: &str = "/opt/nightcrow/libexec/nightcrow-recovery"; - -fn hook_cmd() -> String { - format!("'{EXE}' hook") -} - -fn our_group(settings: &Value) -> &Value { - &settings[HOOKS_KEY][HOOK_EVENT][0] -} - -#[test] -fn merging_into_an_empty_object_adds_both_hooks_and_a_statusline() { - let mut settings = json!({}); - - let (changes, displaced) = merge_into(&mut settings, EXE).unwrap(); - - assert_eq!(changes.len(), 3, "{changes:?}"); - assert_eq!(displaced, Some(Value::Null)); - assert_eq!(our_group(&settings)["matcher"], json!(HOOK_MATCHER)); - assert_eq!( - settings[HOOKS_KEY][TURN_END_EVENT][0]["matcher"], - json!(TURN_END_MATCHER) - ); - assert_eq!( - settings[HOOKS_KEY][TURN_END_EVENT][0][HOOKS_KEY][0]["command"], - json!(format!("'{EXE}' turn-end")) - ); - assert_eq!( - settings[STATUSLINE_KEY]["command"], - json!(format!("'{EXE}' statusline")) - ); -} - -#[test] -fn merging_keeps_unknown_nested_keys_and_unrelated_hook_events() { - let mut settings = json!({ - "unknownTopLevel": [1, 2, 3], - "hooks": { - "unknownNested": "kept", - "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "audit.sh" }] }] - } - }); - - merge_into(&mut settings, EXE).unwrap(); - - assert_eq!(settings["unknownTopLevel"], json!([1, 2, 3])); - assert_eq!(settings["hooks"]["unknownNested"], json!("kept")); - assert_eq!( - settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"], - json!("audit.sh") - ); -} - -#[test] -fn merging_reuses_a_matcher_group_that_already_exists() { - let mut settings = json!({ - "hooks": { "StopFailure": [ - { "matcher": "billing_error", "hooks": [{ "type": "command", "command": "page-me" }] }, - { "matcher": "rate_limit", "hooks": [{ "type": "command", "command": "theirs" }] } - ]} - }); - - merge_into(&mut settings, EXE).unwrap(); - - let groups = settings["hooks"]["StopFailure"].as_array().unwrap(); - assert_eq!(groups.len(), 2, "no new group should be appended"); - assert_eq!(groups[1]["hooks"][0]["command"], json!("theirs")); - assert_eq!(groups[1]["hooks"][1]["command"], json!(hook_cmd())); -} - -#[test] -fn merging_twice_reports_no_change_the_second_time() { - let mut settings = json!({}); - merge_into(&mut settings, EXE).unwrap(); - let after_first = settings.clone(); - - let (changes, displaced) = merge_into(&mut settings, EXE).unwrap(); - - assert!(changes.is_empty(), "{changes:?}"); - assert_eq!(displaced, None); - assert_eq!(settings, after_first); -} - -#[test] -fn merging_over_a_statusline_of_our_own_leaves_it_and_records_nothing() { - let mut settings = json!({ - "statusLine": { "type": "command", "command": format!("{EXE} statusline --wide") } - }); - let before = settings[STATUSLINE_KEY].clone(); - - let (_, displaced) = merge_into(&mut settings, EXE).unwrap(); - - assert_eq!(displaced, None); - assert_eq!(settings[STATUSLINE_KEY], before); -} - -#[test] -fn merging_when_hooks_is_not_an_object_is_refused_and_names_the_field() { - let mut settings = json!({ "hooks": "surprise" }); - - let error = merge_into(&mut settings, EXE).unwrap_err().to_string(); - - assert!(error.contains("`hooks`"), "{error}"); - assert!(error.contains("string"), "{error}"); -} - -#[test] -fn merging_when_the_stop_failure_event_is_not_an_array_is_refused() { - let mut settings = json!({ "hooks": { "StopFailure": { "matcher": "" } } }); - - let error = merge_into(&mut settings, EXE).unwrap_err().to_string(); - - assert!(error.contains("`hooks.StopFailure`"), "{error}"); -} - -#[test] -fn stripping_what_was_merged_returns_the_settings_to_their_original_shape() { - let original = json!({ - "model": "opus", - "hooks": { "PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "audit.sh" }] }] } - }); - let mut settings = original.clone(); - let (_, displaced) = merge_into(&mut settings, EXE).unwrap(); - - let changes = strip_from(&mut settings, displaced).unwrap(); - - assert_eq!(changes.len(), 2, "{changes:?}"); - assert_eq!(settings, original); -} - -#[test] -fn stripping_drops_the_whole_hooks_subtree_when_it_only_held_our_entry() { - let mut settings = json!({ "model": "opus" }); - let (_, displaced) = merge_into(&mut settings, EXE).unwrap(); - - strip_from(&mut settings, displaced).unwrap(); - - assert_eq!(settings, json!({ "model": "opus" })); -} - -#[test] -fn stripping_restores_the_recorded_statusline() { - let theirs = json!({ "type": "command", "command": "mine.sh", "refreshInterval": 1 }); - let mut settings = json!({ "statusLine": theirs.clone() }); - let (_, displaced) = merge_into(&mut settings, EXE).unwrap(); - - strip_from(&mut settings, displaced).unwrap(); - - assert_eq!(settings[STATUSLINE_KEY], theirs); -} - -#[test] -fn stripping_without_a_recorded_value_removes_our_statusline() { - let mut settings = json!({}); - merge_into(&mut settings, EXE).unwrap(); - - strip_from(&mut settings, None).unwrap(); - - assert_eq!(settings.get(STATUSLINE_KEY), None); -} - -#[test] -fn stripping_leaves_a_foreign_hook_command_and_its_group_in_place() { - let original = json!({ - "hooks": { "StopFailure": [ - { "matcher": "rate_limit", "hooks": [{ "type": "command", "command": "theirs" }] } - ]}, - "statusLine": { "type": "command", "command": "theirs.sh" } - }); - let mut settings = original.clone(); - - let changes = strip_from(&mut settings, Some(json!("bogus"))).unwrap(); - - assert!(changes.is_empty(), "{changes:?}"); - assert_eq!(settings, original); -} - -#[test] -fn stripping_keeps_a_users_own_empty_stop_failure_list() { - let original = json!({ "hooks": { "StopFailure": [] } }); - let mut settings = original.clone(); - - strip_from(&mut settings, None).unwrap(); - - assert_eq!(settings, original); -} - -#[test] -fn stripping_a_hooks_subtree_of_an_unexpected_shape_changes_nothing() { - let original = json!({ "hooks": "surprise" }); - let mut settings = original.clone(); - - let changes = strip_from(&mut settings, None).unwrap(); - - assert!(changes.is_empty(), "{changes:?}"); - assert_eq!(settings, original); -} - -#[test] -fn stripping_a_non_object_root_is_refused() { - let mut settings = json!([1, 2]); - - let error = strip_from(&mut settings, None).unwrap_err().to_string(); - - assert!(error.contains("settings root"), "{error}"); - assert!(error.contains("array"), "{error}"); -} - -#[test] -fn stripping_removes_the_turn_end_hook_as_well() { - let mut settings = json!({}); - merge_into(&mut settings, EXE).unwrap(); - - strip_from(&mut settings, Some(Value::Null)).unwrap(); - - assert_eq!(settings.get(HOOKS_KEY), None, "both events went with it"); -} - -/// The bug that cost a user their statusline and both hooks: an unquoted -/// Windows path reaches the shell with every backslash eaten. -#[test] -fn a_windows_path_is_quoted_so_the_shell_keeps_its_separators() { - let exe = r"C:\Users\me\.nightcrow\plugins\nightcrow-recovery"; - - let command = hook_command(exe); - - assert_eq!(command, format!("'{exe}' hook")); - assert!(is_ours(&command), "quoting must not hide our marker"); -} - -#[test] -fn a_quote_in_the_path_is_escaped_rather_than_ending_the_quoting() { - assert_eq!( - hook_command("/opt/it's/nightcrow-recovery"), - r"'/opt/it'\''s/nightcrow-recovery' hook" - ); -} diff --git a/plugins/nightcrow-recovery/src/hooks_tests.rs b/plugins/nightcrow-recovery/src/hooks_tests.rs deleted file mode 100644 index f93bf88c..00000000 --- a/plugins/nightcrow-recovery/src/hooks_tests.rs +++ /dev/null @@ -1,276 +0,0 @@ -use super::*; -use tempfile::TempDir; - -/// A path containing [`MARKER`], as the installed binary's path does. -const EXE: &str = "/opt/nightcrow/libexec/nightcrow-recovery"; - -fn home() -> (TempDir, SettingsPaths) { - let dir = TempDir::new().unwrap(); - let paths = SettingsPaths::from_home(dir.path()); - (dir, paths) -} - -fn write_settings(paths: &SettingsPaths, text: &str) { - fs::create_dir_all(paths.settings.parent().unwrap()).unwrap(); - fs::write(&paths.settings, text).unwrap(); -} - -fn read_value(path: &Path) -> Value { - serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap() -} - -fn hook_cmd() -> String { - format!("'{EXE}' hook") -} - -fn statusline_cmd() -> String { - format!("'{EXE}' statusline") -} - -fn turn_end_cmd() -> String { - format!("'{EXE}' turn-end") -} - -#[test] -fn installing_with_no_settings_file_creates_one_holding_only_our_entries() { - let (_dir, paths) = home(); - - let changes = install(&paths, EXE).unwrap(); - - assert!(!changes.is_empty()); - assert_eq!( - read_value(&paths.settings), - json!({ - "hooks": { - "StopFailure": [{ - "matcher": "rate_limit", - "hooks": [{ "type": "command", "command": hook_cmd(), "timeout": 5 }], - }], - "Stop": [{ - "matcher": "", - "hooks": [{ "type": "command", "command": turn_end_cmd(), "timeout": 5 }], - }], - }, - "statusLine": { "type": "command", "command": statusline_cmd(), "padding": 2 }, - }) - ); -} - -#[test] -fn installing_into_a_settings_file_preserves_unknown_keys() { - let (_dir, paths) = home(); - write_settings( - &paths, - r#"{ - "model": "opus", - "hooks": { - "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "audit.sh" }] }], - "someFutureKey": { "kept": true } - } - }"#, - ); - - install(&paths, EXE).unwrap(); - - let after = read_value(&paths.settings); - assert_eq!(after["model"], json!("opus")); - assert_eq!( - after["hooks"]["PreToolUse"][0]["hooks"][0]["command"], - json!("audit.sh") - ); - assert_eq!(after["hooks"]["someFutureKey"], json!({ "kept": true })); - assert_eq!( - after["hooks"]["StopFailure"][0]["hooks"][0]["command"], - json!(hook_cmd()) - ); -} - -#[test] -fn installing_beside_someone_elses_stop_failure_hook_keeps_both() { - let (_dir, paths) = home(); - write_settings( - &paths, - r#"{"hooks":{"StopFailure":[{"matcher":"rate_limit","hooks":[ - {"type":"command","command":"notify-send limit"}]}]}}"#, - ); - - install(&paths, EXE).unwrap(); - - let entries = read_value(&paths.settings)["hooks"]["StopFailure"][0]["hooks"].clone(); - let commands: Vec<&str> = entries - .as_array() - .unwrap() - .iter() - .map(|e| e["command"].as_str().unwrap()) - .collect(); - assert_eq!(commands, vec!["notify-send limit", hook_cmd().as_str()]); -} - -#[test] -fn installing_twice_changes_nothing_the_second_time_and_reports_so() { - let (_dir, paths) = home(); - install(&paths, EXE).unwrap(); - let first = read_value(&paths.settings); - - let changes = install(&paths, EXE).unwrap(); - - assert_eq!(changes.len(), 1); - assert!(changes[0].contains("nothing changed"), "{}", changes[0]); - assert_eq!(read_value(&paths.settings), first); -} - -#[test] -fn uninstalling_after_installing_restores_the_original_settings() { - let (_dir, paths) = home(); - let original = r#"{ - "model": "opus", - "statusLine": { "type": "command", "command": "~/.claude/mine.sh", "padding": 0 }, - "hooks": { "PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "audit.sh" }] }] } - }"#; - write_settings(&paths, original); - install(&paths, EXE).unwrap(); - - let changes = uninstall(&paths).unwrap(); - - assert!(!changes.is_empty()); - assert_eq!( - read_value(&paths.settings), - serde_json::from_str::(original).unwrap() - ); - assert!(!paths.sidecar.exists()); -} - -#[test] -fn uninstalling_leaves_a_statusline_the_user_replaced_alone() { - let (_dir, paths) = home(); - install(&paths, EXE).unwrap(); - let mut settings = read_value(&paths.settings); - settings["statusLine"] = json!({ "type": "command", "command": "theirs.sh" }); - write_settings(&paths, &settings.to_string()); - - uninstall(&paths).unwrap(); - - let after = read_value(&paths.settings); - assert_eq!( - after["statusLine"], - json!({ "type": "command", "command": "theirs.sh" }) - ); - assert_eq!(after.get("hooks"), None, "our hook should still be gone"); -} - -#[test] -fn uninstalling_with_no_settings_file_is_not_an_error() { - let (_dir, paths) = home(); - - let changes = uninstall(&paths).unwrap(); - - assert_eq!(changes.len(), 1); - assert!(changes[0].contains("nothing to remove"), "{}", changes[0]); -} - -#[test] -fn uninstalling_without_a_sidecar_removes_our_statusline_and_invents_none() { - let (_dir, paths) = home(); - install(&paths, EXE).unwrap(); - fs::remove_file(&paths.sidecar).unwrap(); - - uninstall(&paths).unwrap(); - - assert_eq!(read_value(&paths.settings), json!({})); -} - -#[test] -fn uninstalling_when_nothing_of_ours_is_present_reports_and_writes_nothing() { - let (_dir, paths) = home(); - let original = r#"{"statusLine":{"type":"command","command":"theirs.sh"}}"#; - write_settings(&paths, original); - - let changes = uninstall(&paths).unwrap(); - - assert_eq!(changes.len(), 1); - assert!(changes[0].contains("nothing to remove"), "{}", changes[0]); - assert_eq!(fs::read_to_string(&paths.settings).unwrap(), original); -} - -#[test] -fn installing_over_a_json_array_is_refused_and_the_file_is_untouched() { - assert_refused(r#"[{"statusLine":1}]"#, "array"); -} - -#[test] -fn installing_over_a_json_scalar_is_refused_and_the_file_is_untouched() { - assert_refused("42", "number"); -} - -#[test] -fn installing_over_malformed_json_is_refused_and_the_file_is_untouched() { - assert_refused("{ this is not json", "not valid JSON"); -} - -fn assert_refused(original: &str, expected_in_error: &str) { - let (_dir, paths) = home(); - write_settings(&paths, original); - - let error = install(&paths, EXE).unwrap_err().to_string(); - - assert!(error.contains(expected_in_error), "{error}"); - assert!( - error.contains(&paths.settings.display().to_string()), - "error must name the file: {error}" - ); - assert_eq!(fs::read_to_string(&paths.settings).unwrap(), original); - assert!(!paths.backup.exists()); - assert!(!paths.sidecar.exists()); -} - -#[test] -#[cfg(unix)] -fn installing_backs_up_the_previous_file_and_writes_mode_0600() { - use std::os::unix::fs::PermissionsExt; - let (_dir, paths) = home(); - let original = r#"{"model":"opus"}"#; - write_settings(&paths, original); - - install(&paths, EXE).unwrap(); - - assert_eq!(fs::read_to_string(&paths.backup).unwrap(), original); - let mode = fs::metadata(&paths.settings).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600, "mode was {mode:o}"); -} - -#[test] -fn installing_without_a_settings_file_writes_no_backup() { - let (_dir, paths) = home(); - - install(&paths, EXE).unwrap(); - - assert!(!paths.backup.exists()); -} - -#[test] -fn installing_through_a_binary_without_the_marker_is_refused() { - let (_dir, paths) = home(); - - let error = install(&paths, "/usr/bin/renamed").unwrap_err().to_string(); - - assert!(error.contains(MARKER), "{error}"); - assert!(!paths.settings.exists()); -} - -#[test] -fn settings_paths_from_home_composes_the_documented_paths() { - let paths = SettingsPaths::from_home(Path::new("/home/dev")); - - assert_eq!( - paths.settings, - PathBuf::from("/home/dev/.claude/settings.json") - ); - assert_eq!( - paths.backup, - PathBuf::from("/home/dev/.claude/settings.json.bak") - ); - assert_eq!( - paths.sidecar, - PathBuf::from("/home/dev/.claude/nightcrow-recovery.displaced.json") - ); -} diff --git a/plugins/nightcrow-recovery/src/ipc.rs b/plugins/nightcrow-recovery/src/ipc.rs deleted file mode 100644 index 69734004..00000000 --- a/plugins/nightcrow-recovery/src/ipc.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! The private socket a provider's helper processes report through. -//! -//! Claude Code invokes a hook and a statusline command as children of the -//! `claude` process, so they inherit the [`PANE_TOKEN_ENV`] value nightcrow -//! injected. Those children live for milliseconds and must not block their -//! parent: connect, write one line, exit. This module is that line's format -//! and both ends of the socket. -//! -//! Trust posture: anything that can reach the socket can claim to be any pane, -//! so the socket is created 0600 inside a 0700 directory and every field is -//! validated before it reaches the state machine. The token is a correlation -//! key, never an authorisation — the worst a forged message can do is make -//! this plugin ask the host for something, and the host judges that again. - -use crate::protocol::PaneToken; -use crate::provider::{OutOfBand, SignalKind}; -use crate::transport::{UnixListener, UnixStream}; -use anyhow::{Context, Result, bail, ensure}; -use serde_json::Value; -use std::ffi::OsStr; -use std::fs; -use std::io::{BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; -use std::time::Duration; - -/// IPC message version, independent of the host protocol: this socket is between -/// two copies of this same binary, so a mismatch means a half-finished upgrade. -const IPC_VERSION: u32 = 1; - -/// Directory and file name under the chosen runtime root. -const RUNTIME_DIR: &str = "nightcrow"; -const SOCKET_FILE: &str = "recovery.sock"; -/// Fallback root when `$XDG_RUNTIME_DIR` is unset (it usually is on macOS). -const HOME_RUNTIME_DIR: &str = ".nightcrow/run"; - -/// Only the owner may traverse the directory or speak to the socket. A pane -/// token is a correlation key and not a secret, but there is no reason to let -/// another local user inject one. -const DIR_MODE: u32 = 0o700; -const SOCKET_MODE: u32 = 0o600; - -/// Restrict a path to its owner on platforms where that is meaningful. -/// On Windows the default ACL on a user-owned directory already suffices. -#[cfg(unix)] -fn restrict_to_owner(path: &Path, mode: u32) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(mode)) - .with_context(|| format!("cannot restrict {} to its owner", path.display())) -} - -#[cfg(not(unix))] -fn restrict_to_owner(_path: &Path, _mode: u32) -> Result<()> { - Ok(()) -} - -/// Longest IPC line accepted. Both senders forward a whitelist of small scalar -/// fields, so anything larger is a bug or an attempt to make this process -/// allocate. -pub const MAX_IPC_LINE_BYTES: usize = 8 * 1024; - -/// Longest token accepted. The host mints 32 hex characters; the cap is double -/// that so a future widening does not need a change here. -const MAX_TOKEN_LEN: usize = 64; - -/// How long either end will block on the socket. The sender runs inside a -/// provider's hook child, so it must give up quickly rather than hold up -/// someone's CLI; the receiver uses the same bound so one stalled client -/// cannot park the accept loop. -const IPC_TIMEOUT: Duration = Duration::from_millis(500); - -/// One report from a provider helper process. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IpcMessage { - pub token: PaneToken, - pub kind: SignalKind, - pub payload: Value, -} - -impl IpcMessage { - pub fn into_signal(self) -> (PaneToken, OutOfBand) { - ( - self.token, - OutOfBand { - kind: self.kind, - payload: self.payload, - }, - ) - } -} - -/// Env var the host sets on a plugin process and on the panes of the same hub, -/// naming the directory they share. See nightcrow's `PLUGIN_RUNTIME_DIR_ENV`. -pub const RUNTIME_DIR_ENV: &str = "NIGHTCROW_PLUGIN_RUNTIME_DIR"; - -/// Where the socket lives. -/// -/// The host's directory when it named one: a hub is per repository, so a -/// session with several projects runs several of this binary and one fixed -/// path would let only the first bind — a helper inside a pane would then -/// reach whichever instance won rather than the one watching it. Falling back -/// to the old fixed location keeps this runnable by hand and under a host too -/// old to say. -pub fn socket_path() -> Result { - if let Some(dir) = std::env::var_os(RUNTIME_DIR_ENV).filter(|d| !d.is_empty()) { - return Ok(PathBuf::from(dir).join(SOCKET_FILE)); - } - socket_path_from( - std::env::var_os("XDG_RUNTIME_DIR").as_deref(), - dirs::home_dir().map(std::ffi::OsString::from).as_deref(), - ) -} - -/// The path rule itself, separated from the environment so it can be tested -/// without mutating this process's variables. -fn socket_path_from(runtime_dir: Option<&OsStr>, home: Option<&OsStr>) -> Result { - if let Some(dir) = runtime_dir.filter(|d| !d.is_empty()) { - return Ok(PathBuf::from(dir).join(RUNTIME_DIR).join(SOCKET_FILE)); - } - let home = home.filter(|h| !h.is_empty()).context( - "neither XDG_RUNTIME_DIR nor a home directory is set, so there is nowhere to put the socket", - )?; - Ok(PathBuf::from(home).join(HOME_RUNTIME_DIR).join(SOCKET_FILE)) -} - -pub fn encode(msg: &IpcMessage) -> Result { - let line = serde_json::json!({ - "v": IPC_VERSION, - "token": msg.token, - "kind": msg.kind.as_wire(), - "payload": msg.payload, - }) - .to_string(); - ensure!( - line.len() <= MAX_IPC_LINE_BYTES, - "ipc message is {} bytes, over the {MAX_IPC_LINE_BYTES}-byte limit", - line.len() - ); - Ok(line) -} - -/// Parse and fully validate one line from the socket. -/// -/// Every failure names what was wrong: this is the boundary where untrusted -/// input becomes state, so a silently-coerced field would be the bug. -pub fn parse_line(line: &str) -> Result { - ensure!( - line.len() <= MAX_IPC_LINE_BYTES, - "ipc line is {} bytes, over the {MAX_IPC_LINE_BYTES}-byte limit", - line.len() - ); - let value: Value = - serde_json::from_str(line).map_err(|e| anyhow::anyhow!("ipc line is not JSON: {e}"))?; - let object = value.as_object().context("ipc line is not a JSON object")?; - let v = object - .get("v") - .and_then(Value::as_u64) - .context("ipc line has no numeric \"v\"")?; - ensure!( - v == u64::from(IPC_VERSION), - "ipc line claims version {v}, this build speaks {IPC_VERSION}" - ); - let token = object - .get("token") - .and_then(Value::as_str) - .context("ipc line has no string \"token\"")?; - ensure!(!token.is_empty(), "ipc line carries an empty token"); - ensure!( - token.len() <= MAX_TOKEN_LEN, - "ipc token is {} characters, over the {MAX_TOKEN_LEN} limit", - token.len() - ); - ensure!( - token.chars().all(|c| c.is_ascii_alphanumeric()), - "ipc token holds characters a pane token cannot contain" - ); - let kind_name = object - .get("kind") - .and_then(Value::as_str) - .context("ipc line has no string \"kind\"")?; - let kind = SignalKind::from_wire(kind_name) - .with_context(|| format!("unknown ipc kind {kind_name:?}"))?; - let payload = object - .get("payload") - .context("ipc line has no \"payload\"")?; - ensure!( - payload.is_object(), - "ipc payload is not a JSON object, so there is nothing to read from it" - ); - Ok(IpcMessage { - token: token.to_string(), - kind, - payload: payload.clone(), - }) -} - -/// Send one message and return. Used by the short-lived `hook` and `statusline` -/// modes, where a failure must be silent: the provider's helper process has no -/// business reporting our problems to its user. -pub fn send(path: &Path, msg: &IpcMessage) -> Result<()> { - let line = encode(msg)?; - let mut stream = UnixStream::connect(path) - .with_context(|| format!("no recovery plugin listening at {}", path.display()))?; - stream.set_write_timeout(Some(IPC_TIMEOUT))?; - stream.write_all(line.as_bytes())?; - stream.write_all(b"\n")?; - stream.flush()?; - Ok(()) -} - -/// The listening end. Unlinks its socket when dropped, so a normal exit leaves -/// nothing behind for the next run to trip over. -#[derive(Debug)] -pub struct Ipc { - path: PathBuf, - listener: UnixListener, -} - -impl Ipc { - pub fn bind(path: PathBuf) -> Result { - let dir = path - .parent() - .context("socket path has no parent directory")?; - fs::create_dir_all(dir) - .with_context(|| format!("cannot create runtime directory {}", dir.display()))?; - restrict_to_owner(dir, DIR_MODE)?; - // A socket file left by a crashed run refuses bind with EADDRINUSE, and - // there is no live listener behind it to protect. - if path.exists() && UnixStream::connect(&path).is_err() { - let _ = fs::remove_file(&path); - } - let listener = UnixListener::bind(&path).with_context(|| { - let len = path.as_os_str().len(); - if len >= 108 { - format!( - "cannot listen on {} — the path is {len} bytes, over the ~107 byte AF_UNIX limit", - path.display() - ) - } else { - format!("cannot listen on {}", path.display()) - } - })?; - restrict_to_owner(&path, SOCKET_MODE)?; - Ok(Self { path, listener }) - } - - pub fn path(&self) -> &Path { - &self.path - } - - /// Start accepting in a background thread. One line per connection: a - /// helper process has exactly one thing to say. - /// - /// `sink` returns `false` when the receiver is gone, which ends the thread. - pub fn serve(&self, sink: S) -> Result<()> - where - S: Fn(IpcMessage) -> bool + Send + 'static, - { - let listener = self - .listener - .try_clone() - .context("cannot clone the ipc listener for its accept thread")?; - std::thread::Builder::new() - .name("recovery-ipc".to_string()) - .spawn(move || { - for stream in listener.incoming() { - let Ok(stream) = stream else { continue }; - let _ = stream.set_read_timeout(Some(IPC_TIMEOUT)); - match read_one(stream) { - Ok(msg) => { - if !sink(msg) { - return; // the main loop is gone - } - } - // A malformed or truncated message is dropped: there is - // no channel back to a caller that has already exited. - Err(_) => continue, - } - } - }) - .context("cannot spawn the ipc accept thread")?; - Ok(()) - } -} - -impl Drop for Ipc { - fn drop(&mut self) { - let _ = fs::remove_file(&self.path); - } -} - -fn read_one(stream: UnixStream) -> Result { - let mut reader = BufReader::new(stream).take(MAX_IPC_LINE_BYTES as u64 + 1); - let mut line = String::new(); - let read = reader.read_line(&mut line)?; - if read == 0 { - bail!("ipc connection closed without sending a line"); - } - parse_line(line.trim_end_matches('\n')) -} - -#[cfg(test)] -#[path = "ipc_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/ipc_tests.rs b/plugins/nightcrow-recovery/src/ipc_tests.rs deleted file mode 100644 index 6bc42716..00000000 --- a/plugins/nightcrow-recovery/src/ipc_tests.rs +++ /dev/null @@ -1,221 +0,0 @@ -use super::*; -use std::sync::mpsc::channel; - -const TOKEN: &str = "0123456789abcdef0123456789abcdef"; -const OTHER_TOKEN: &str = "ffffffffffffffffffffffffffffffff"; - -fn message(token: &str, kind: SignalKind) -> IpcMessage { - IpcMessage { - token: token.to_string(), - kind, - payload: serde_json::json!({"session_id": "abc", "error_type": "rate_limit"}), - } -} - -fn line(token: &str) -> String { - format!(r#"{{"v":1,"token":"{token}","kind":"stop_failure","payload":{{"a":1}}}}"#) -} - -#[test] -fn a_message_round_trips_through_its_wire_line() { - let sent = message(TOKEN, SignalKind::StopFailure); - let parsed = parse_line(&encode(&sent).expect("encodable")).expect("parsable"); - assert_eq!(parsed, sent); -} - -#[test] -fn a_statusline_message_round_trips_too() { - let sent = IpcMessage { - token: TOKEN.to_string(), - kind: SignalKind::RateLimits, - payload: serde_json::json!({"five_hour": {"resets_at": 1_767_225_600}}), - }; - let parsed = parse_line(&encode(&sent).expect("encodable")).expect("parsable"); - assert_eq!(parsed, sent); -} - -#[test] -fn a_line_that_is_not_a_json_object_is_refused() { - for bad in ["", " ", "not json", "[1,2]", "\"a\"", "null", "7"] { - assert!(parse_line(bad).is_err(), "{bad:?} is not a message"); - } -} - -#[test] -fn a_line_from_another_ipc_version_is_refused() { - let bad = line(TOKEN).replace("\"v\":1", "\"v\":2"); - let err = parse_line(&bad) - .expect_err("a version mismatch") - .to_string(); - assert!(err.contains('2'), "{err}"); -} - -#[test] -fn a_line_with_no_version_is_refused() { - let bad = line(TOKEN).replace("\"v\":1,", ""); - assert!(parse_line(&bad).is_err()); -} - -#[test] -fn a_token_that_no_host_would_mint_is_refused() { - for token in ["", "abc def", "abc;rm", "../../etc/passwd", "tokén"] { - let err = parse_line(&line(token)); - assert!(err.is_err(), "{token:?} is not a pane token"); - } - let long = "a".repeat(MAX_IPC_LINE_BYTES.min(200)); - assert!(parse_line(&line(&long)).is_err(), "an over-long token"); -} - -#[test] -fn an_unknown_kind_is_refused_rather_than_ignored() { - let bad = line(TOKEN).replace("stop_failure", "transcript"); - let err = parse_line(&bad).expect_err("an unknown kind").to_string(); - assert!(err.contains("transcript"), "{err}"); -} - -#[test] -fn a_payload_that_is_not_an_object_is_refused() { - for payload in ["null", "\"text\"", "[1]", "3"] { - let bad = line(TOKEN).replace(r#"{"a":1}"#, payload); - assert!(parse_line(&bad).is_err(), "{payload} is not a payload"); - } -} - -#[test] -fn a_line_over_the_length_limit_is_refused_before_it_is_parsed() { - let bad = format!("{}{}", line(TOKEN), " ".repeat(MAX_IPC_LINE_BYTES)); - let err = parse_line(&bad).expect_err("over the limit").to_string(); - assert!(err.contains("limit"), "{err}"); -} - -#[test] -fn encoding_refuses_a_payload_too_large_to_send() { - let huge = IpcMessage { - token: TOKEN.to_string(), - kind: SignalKind::StopFailure, - payload: serde_json::json!({"error_message": "x".repeat(MAX_IPC_LINE_BYTES)}), - }; - assert!(encode(&huge).is_err()); -} - -#[test] -fn a_message_becomes_a_signal_keyed_by_its_pane_token() { - let (token, signal) = message(TOKEN, SignalKind::RateLimits).into_signal(); - assert_eq!(token, TOKEN); - assert_eq!(signal.kind, SignalKind::RateLimits); -} - -#[test] -fn the_socket_path_prefers_the_systems_runtime_directory() { - let path = socket_path_from( - Some(OsStr::new("/run/user/1000")), - Some(OsStr::new("/home/x")), - ) - .expect("a path"); - assert_eq!( - path, - PathBuf::from("/run/user/1000/nightcrow/recovery.sock") - ); -} - -#[test] -fn the_socket_path_falls_back_to_the_home_directory() { - for runtime in [None, Some(OsStr::new(""))] { - let path = socket_path_from(runtime, Some(OsStr::new("/home/x"))).expect("a path"); - assert_eq!(path, PathBuf::from("/home/x/.nightcrow/run/recovery.sock")); - } -} - -#[test] -fn a_process_with_neither_variable_set_has_nowhere_to_put_the_socket() { - let err = socket_path_from(None, None) - .expect_err("nowhere to put it") - .to_string(); - assert!( - err.contains("XDG_RUNTIME_DIR") && err.contains("home directory"), - "{err}" - ); - assert!(socket_path_from(Some(OsStr::new("")), Some(OsStr::new(""))).is_err()); -} - -#[test] -fn a_helpers_line_reaches_the_plugin_and_names_its_pane() { - let dir = tempfile::tempdir().expect("a temp dir"); - let path = dir.path().join("run").join("recovery.sock"); - let ipc = Ipc::bind(path.clone()).expect("a listener"); - let (tx, rx) = channel(); - ipc.serve(move |msg| tx.send(msg).is_ok()).expect("serving"); - - send(&path, &message(TOKEN, SignalKind::StopFailure)).expect("sent"); - send(&path, &message(OTHER_TOKEN, SignalKind::RateLimits)).expect("sent"); - - let first = rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("first"); - let second = rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("second"); - let tokens = [first.token.clone(), second.token.clone()]; - assert!(tokens.contains(&TOKEN.to_string())); - assert!(tokens.contains(&OTHER_TOKEN.to_string())); -} - -#[test] -fn a_malformed_line_is_dropped_without_ending_the_listener() { - use std::io::Write as _; - let dir = tempfile::tempdir().expect("a temp dir"); - let path = dir.path().join("recovery.sock"); - let ipc = Ipc::bind(path.clone()).expect("a listener"); - let (tx, rx) = channel(); - ipc.serve(move |msg| tx.send(msg).is_ok()).expect("serving"); - - let mut stream = UnixStream::connect(&path).expect("connected"); - stream.write_all(b"garbage\n").expect("wrote"); - drop(stream); - - send(&path, &message(TOKEN, SignalKind::StopFailure)).expect("sent"); - let received = rx - .recv_timeout(std::time::Duration::from_secs(5)) - .expect("the good line"); - assert_eq!(received.token, TOKEN); -} - -#[test] -#[cfg(unix)] -fn the_socket_is_readable_only_by_its_owner_and_removed_on_exit() { - use std::os::unix::fs::PermissionsExt; - let dir = tempfile::tempdir().expect("a temp dir"); - let path = dir.path().join("recovery.sock"); - { - let ipc = Ipc::bind(path.clone()).expect("a listener"); - assert_eq!(ipc.path(), path.as_path()); - let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; - assert_eq!(mode, SOCKET_MODE); - let dir_mode = fs::metadata(dir.path()) - .expect("metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(dir_mode, DIR_MODE); - } - assert!(!path.exists(), "a normal exit leaves no socket behind"); -} - -#[test] -fn a_socket_left_by_a_crashed_run_is_replaced() { - let dir = tempfile::tempdir().expect("a temp dir"); - let path = dir.path().join("recovery.sock"); - fs::write(&path, b"stale").expect("a leftover file"); - let ipc = Ipc::bind(path.clone()).expect("bind over the leftover"); - assert!(ipc.path().exists()); -} - -#[test] -fn sending_to_a_plugin_that_is_not_running_fails_without_blocking() { - let dir = tempfile::tempdir().expect("a temp dir"); - let path = dir.path().join("absent.sock"); - let err = send(&path, &message(TOKEN, SignalKind::StopFailure)) - .expect_err("nothing is listening") - .to_string(); - assert!(err.contains("absent.sock"), "{err}"); -} diff --git a/plugins/nightcrow-recovery/src/main.rs b/plugins/nightcrow-recovery/src/main.rs index 7b59355f..8ed431f4 100644 --- a/plugins/nightcrow-recovery/src/main.rs +++ b/plugins/nightcrow-recovery/src/main.rs @@ -2,99 +2,27 @@ //! plan's usage limit, waits for the limit to reset, and resumes the exact //! session it was in. //! -//! With no subcommand this is the plugin itself: NDJSON on stdin and stdout, -//! spoken to nightcrow. The subcommands are the parts a provider invokes or a -//! human runs once — see each one's help text. +//! The executable is the plugin itself: NDJSON on stdin and stdout, spoken to +//! nightcrow. //! //! What this program will not do, by construction: name the program a pane runs //! (the host owns that), alter a CLI's permission flags (only resume arguments //! are ever passed), or write anything down beyond the recovery metadata it //! needs while it is running. -mod helper; -mod hooks; -mod ipc; mod protocol; mod provider; mod runloop; -mod runloop_adopt; mod runloop_io; mod state; -pub(crate) mod transport; mod wait; -use clap::{Parser, Subcommand}; use std::process::ExitCode; -#[derive(Debug, Parser)] -#[command(name = "nightcrow-recovery", version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - mode: Option, -} - -#[derive(Debug, Subcommand)] -enum Mode { - /// Add the Claude Code StopFailure hook and statusline entries to - /// ~/.claude/settings.json, merging into whatever is already there. - InstallHooks, - /// Remove only the entries install-hooks added. - UninstallHooks, - /// Internal: the command Claude Code runs for StopFailure. Reads the hook - /// payload on stdin and forwards a few fields to the running plugin. - Hook, - /// Internal: the command Claude Code runs when a turn ends. Tells the - /// running plugin, which asks the host to mark the pane's project tab. - TurnEnd, - /// Internal: the command Claude Code runs for its statusline. Forwards the - /// usage windows to the running plugin, then prints the statusline this - /// plugin displaced at install time, or a short line of its own. - Statusline, -} - fn main() -> ExitCode { - match Cli::parse().mode { - None => report(runloop::run()), - Some(Mode::InstallHooks) => report(install()), - Some(Mode::UninstallHooks) => report(uninstall()), - Some(Mode::Hook) => helper::hook(), - Some(Mode::TurnEnd) => helper::turn_end(), - Some(Mode::Statusline) => helper::statusline(), - } -} - -fn install() -> anyhow::Result<()> { - let paths = hooks::SettingsPaths::discover()?; - print_changes(hooks::install(&paths, ¤t_exe()?)?); - Ok(()) -} - -fn uninstall() -> anyhow::Result<()> { - let paths = hooks::SettingsPaths::discover()?; - print_changes(hooks::uninstall(&paths)?); - Ok(()) -} - -fn print_changes(changes: Vec) { - for change in changes { - println!("{change}"); - } -} - -/// The absolute path to write into the provider's settings. -/// -/// Resolved rather than taken from `argv[0]`: the settings file is read by a -/// different process with a different working directory, so a relative name -/// there would silently stop working. -fn current_exe() -> anyhow::Result { - let exe = std::env::current_exe()?; - exe.to_str() - .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("this executable's path is not valid UTF-8: {exe:?}")) + report(runloop::run()) } -/// A human-facing mode's exit status. The message goes to stderr so a mode that -/// also prints data keeps its stdout clean. fn report(result: anyhow::Result<()>) -> ExitCode { match result { Ok(()) => ExitCode::SUCCESS, diff --git a/plugins/nightcrow-recovery/src/protocol.rs b/plugins/nightcrow-recovery/src/protocol.rs index adffd075..0bd37dcd 100644 --- a/plugins/nightcrow-recovery/src/protocol.rs +++ b/plugins/nightcrow-recovery/src/protocol.rs @@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize}; /// Contract version this plugin speaks. The host refuses anything else. /// -/// 2 is the first version with [`PluginCommand::WatchPane`], which this plugin -/// needs: a pane somebody started a provider CLI in by hand is never named to -/// us, so asking for it is the only way to watch it at all. +/// Version 3 includes the provider-agnostic watch and attention commands even +/// though this bundled plugin currently acts only on panes explicitly assigned +/// to it. Keeping the full shape catches drift against the host contract. pub const PROTOCOL_VERSION: u32 = 3; /// Longest line the host will read from us; also the cap we apply to what we @@ -20,6 +20,10 @@ pub const PROTOCOL_VERSION: u32 = 3; pub const MAX_LINE_BYTES: usize = 64 * 1024; /// Longest `data` the host accepts in one [`PluginCommand::SendInput`]. +#[allow( + dead_code, + reason = "kept in this standalone protocol copy to catch host contract drift" +)] pub const MAX_INPUT_BYTES: usize = 8 * 1024; /// Opaque pane-slot name. Random hex minted by the host; we only ever compare @@ -29,12 +33,6 @@ pub type PaneToken = String; /// Which spawn of a pane slot an event or command refers to. pub type PaneGeneration = u32; -/// Env var carrying the pane token into the pane's child processes, and hence -/// into a provider CLI's hook and statusline helpers. That inheritance is how -/// an out-of-band signal is attributed to a pane; cwd cannot do it, because -/// nightcrow allows several panes on one repository. -pub const PANE_TOKEN_ENV: &str = "NIGHTCROW_PANE_TOKEN"; - /// Something the host observed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] @@ -202,21 +200,6 @@ pub fn encode_command(cmd: &PluginCommand) -> anyhow::Result { Ok(line) } -pub fn watch_pane(token: PaneToken) -> PluginCommand { - PluginCommand::WatchPane { - v: PROTOCOL_VERSION, - token, - } -} - -pub fn attention(token: PaneToken, generation: PaneGeneration) -> PluginCommand { - PluginCommand::Attention { - v: PROTOCOL_VERSION, - token, - generation, - } -} - pub fn log(level: LogLevel, message: impl Into) -> PluginCommand { PluginCommand::Log { v: PROTOCOL_VERSION, diff --git a/plugins/nightcrow-recovery/src/protocol_tests.rs b/plugins/nightcrow-recovery/src/protocol_tests.rs index 12ad399e..5c4ffef1 100644 --- a/plugins/nightcrow-recovery/src/protocol_tests.rs +++ b/plugins/nightcrow-recovery/src/protocol_tests.rs @@ -4,7 +4,7 @@ const TOKEN: &str = "0123456789abcdef0123456789abcdef"; fn opened_line() -> String { format!( - r#"{{"event":"pane_opened","v":{PROTOCOL_VERSION},"token":"{TOKEN}","generation":2,"title":null,"command":"claude","cwd":"/w/repo"}}"# + r#"{{"event":"pane_opened","v":{PROTOCOL_VERSION},"token":"{TOKEN}","generation":2,"title":null,"command":"codex","cwd":"/w/repo"}}"# ) } @@ -18,7 +18,7 @@ fn a_host_event_is_parsed_from_its_wire_shape() { token: TOKEN.to_string(), generation: 2, title: None, - command: Some("claude".to_string()), + command: Some("codex".to_string()), cwd: "/w/repo".to_string(), } ); @@ -44,9 +44,8 @@ fn an_event_from_another_protocol_version_is_refused_and_both_versions_named() { #[test] fn an_event_from_the_previous_protocol_version_is_refused_too() { - // Refused in both directions on purpose. A host still speaking 1 has no - // `watch_pane` to honour, so the panes this plugin exists for would silently - // never be watched — better to fail at the first line than halfway. + // Refused in both directions on purpose. Better to fail at the first line + // than to run against a partly compatible command set. let line = opened_line().replace(&format!("\"v\":{PROTOCOL_VERSION}"), "\"v\":1"); assert!(decode_event(&line).is_err()); } @@ -103,7 +102,11 @@ fn a_watch_pane_request_carries_only_the_token() { // Deliberately no generation: this is asked about a pane the host has never // described to us, so any generation we put here would be invented — and the // host would be right to refuse it. - let line = encode_command(&watch_pane(TOKEN.to_string())).expect("encodable"); + let line = encode_command(&PluginCommand::WatchPane { + v: PROTOCOL_VERSION, + token: TOKEN.to_string(), + }) + .expect("encodable"); assert!(line.contains("\"cmd\":\"watch_pane\""), "{line}"); assert!(line.contains(&format!("\"token\":\"{TOKEN}\"")), "{line}"); assert!(!line.contains("generation"), "{line}"); @@ -138,7 +141,6 @@ fn the_input_limit_matches_what_the_host_accepts() { // spend attempts on requests the host refuses. assert_eq!(MAX_INPUT_BYTES, 8 * 1024); assert_eq!(MAX_LINE_BYTES, 64 * 1024); - assert_eq!(PANE_TOKEN_ENV, "NIGHTCROW_PANE_TOKEN"); } /// Pull `pub const NAME: ty = ;` out of the host's source. diff --git a/plugins/nightcrow-recovery/src/provider/claude.rs b/plugins/nightcrow-recovery/src/provider/claude.rs deleted file mode 100644 index 51b1d121..00000000 --- a/plugins/nightcrow-recovery/src/provider/claude.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Claude Code adapter. -//! -//! Three sources, in descending order of trust: the `StopFailure` hook (says -//! exactly why a turn ended), the statusline's `rate_limits` object (says when a -//! window resets, but never that we are blocked), and pane text (a fallback for -//! users who have neither, i.e. no hook installed and no Pro/Max subscription). - -use super::{ - LimitEvent, LimitKind, OutOfBand, PaneContext, Provider, ResumePlan, SignalKind, - reset_epoch_from_json, -}; -use crate::protocol::PaneGeneration; -use serde_json::Value; - -/// Hook name we require when the payload names itself, so a mislabelled or -/// replayed payload cannot be read as a stop failure. -const STOP_FAILURE_EVENT: &str = "StopFailure"; - -/// Windows the statusline may report. Each is independently optional: the object -/// only exists for Pro/Max accounts, and only after the session's first response. -const RATE_LIMIT_WINDOWS: &[&str] = &["five_hour", "seven_day"]; - -/// A session id is a UUID (36 chars); the cap is deliberately loose but finite so -/// an over-long value is rejected rather than handed to a command line. -const MAX_SESSION_ID_BYTES: usize = 64; - -/// How much recent output is kept so a needle split across two `pane_output` -/// events is still found. One screenful of a wide terminal is a few KiB; 4 KiB -/// spans that without holding transcript-sized history in memory. -const OUTPUT_TAIL_BYTES: usize = 4 * 1024; - -/// Phrasings that unambiguously mean the account is blocked by usage. Compared -/// against lowercased text. Kept narrow on purpose: a false positive parks a -/// working pane, and the hook already covers the common case. -const LIMIT_NEEDLES: &[&str] = &[ - "usage limit reached", - // Covers both the ASCII and the typographic apostrophe in "you've". - "hit your usage limit", -]; - -/// Phrasings that look similar but only warn. Checked before [`LIMIT_NEEDLES`]; -/// suppressing is the safe direction, because a missed limit still reaches the -/// machine through the hook or the next output chunk. -const NOT_A_LIMIT_NEEDLES: &[&str] = &[ - "approaching your usage limit", - "approaching the usage limit", -]; - -/// Typed into a live pane. Claude Code keeps running after an API error, so the -/// recovery is a nudge, not a relaunch. A plain continuation word only — never a -/// flag, never a permission grant. -const NUDGE_INPUT: &str = "continue\r"; - -/// Flag that resumes a named session; the id follows as a positional argument. -const RESUME_FLAG: &str = "--resume"; - -const NEEDS_HUMAN_HOLD: &str = - "claude reported an auth or billing failure; waiting cannot clear it"; -const NO_SESSION_HOLD: &str = - "no Claude session id; resuming the wrong session is worse than stopping"; -const OUTPUT_FALLBACK_DETAIL: &str = "claude output says the usage limit is reached"; - -/// Adapter state for one pane. Nothing here is written to disk, and neither -/// `error_message` nor transcript text is ever retained. -#[derive(Debug, Default)] -pub struct Claude { - /// Generation the rest of this state belongs to; a change re-arms the latch. - generation: Option, - /// Earliest plausible reset time the statusline has reported. - resets_at: Option, - /// Last validated session id seen on a hook payload. - session_id: Option, - /// Lowercased tail of recent output, at most [`OUTPUT_TAIL_BYTES`]. - tail: String, - /// Whether the output fallback has already fired for this generation. - fired: bool, -} - -impl Claude { - fn sync_generation(&mut self, ctx: &PaneContext) { - if self.generation == Some(ctx.generation) { - return; - } - self.generation = Some(ctx.generation); - self.fired = false; - self.tail.clear(); - // A respawn is a different session, so the old id must not be reused. - // The reset time is an account-wide fact and survives the respawn. - self.session_id = None; - } - - fn remember_reset(&mut self, at: i64) { - // The earliest window to reopen is the one that decides when work can - // continue, so the minimum is the useful deadline. - self.resets_at = Some(match self.resets_at { - Some(known) => known.min(at), - None => at, - }); - } - - fn push_tail(&mut self, text: &str) { - self.tail.push_str(&text.to_lowercase()); - if self.tail.len() <= OUTPUT_TAIL_BYTES { - return; - } - let want = self.tail.len() - OUTPUT_TAIL_BYTES; - let cut = (want..=self.tail.len()) - .find(|i| self.tail.is_char_boundary(*i)) - .unwrap_or(self.tail.len()); - self.tail.drain(..cut); - } - - fn on_rate_limits(&mut self, payload: &Value, now_epoch: i64) { - for window in RATE_LIMIT_WINDOWS { - if let Some(at) = reset_epoch_from_json(payload, &[window, "resets_at"], now_epoch) { - self.remember_reset(at); - } - } - // `used_percentage` is deliberately ignored, including at 100: a full - // window corroborates a limit but does not declare one, and only - // StopFailure or the output fallback may declare. - } - - fn on_stop_failure(&mut self, payload: &Value) -> Option { - let named = payload.get("hook_event_name").and_then(Value::as_str); - if named.is_some_and(|name| name != STOP_FAILURE_EVENT) { - return None; - } - let error_type = payload.get("error_type").and_then(Value::as_str)?; - let (kind, detail) = classify(error_type)?; - if let Some(id) = payload - .get("session_id") - .and_then(Value::as_str) - .and_then(validated_session_id) - { - self.session_id = Some(id); - } - Some(LimitEvent { - session_id: self.session_id.clone(), - resets_at: self.resets_at, - kind, - detail: detail.to_string(), - }) - } -} - -impl Provider for Claude { - fn name(&self) -> &'static str { - "claude" - } - - fn on_signal( - &mut self, - ctx: &PaneContext, - signal: &OutOfBand, - now_epoch: i64, - ) -> Option { - self.sync_generation(ctx); - match signal.kind { - SignalKind::RateLimits => { - self.on_rate_limits(&signal.payload, now_epoch); - None - } - SignalKind::StopFailure => self.on_stop_failure(&signal.payload), - // Intercepted before a provider ever sees it: a turn ending is a - // fact about the person's attention, not about usage limits. - SignalKind::TurnEnd => None, - } - } - - fn on_output(&mut self, ctx: &PaneContext, text: &str, _now_epoch: i64) -> Option { - self.sync_generation(ctx); - self.push_tail(text); - if self.fired { - return None; - } - if NOT_A_LIMIT_NEEDLES.iter().any(|n| self.tail.contains(n)) { - return None; - } - if !LIMIT_NEEDLES.iter().any(|n| self.tail.contains(n)) { - return None; - } - // A TUI redraws the same line many times, so latch and drop the matched - // text instead of reporting it again on the next repaint. - self.fired = true; - self.tail.clear(); - // No wall-clock time is parsed out of the text: it is printed without an - // offset, so a deadline read from it would be ambiguous. - Some(LimitEvent::usage( - self.session_id.clone(), - self.resets_at, - OUTPUT_FALLBACK_DETAIL, - )) - } - - fn on_exit(&mut self, _ctx: &PaneContext) { - self.fired = false; - self.tail.clear(); - } - - fn resume(&self, _ctx: &PaneContext, limit: &LimitEvent, alive: bool) -> Option { - if limit.kind == LimitKind::NeedsHuman { - return Some(ResumePlan::Hold(NEEDS_HUMAN_HOLD)); - } - if alive { - return Some(ResumePlan::Input(NUDGE_INPUT.to_string())); - } - let Some(id) = limit.session_id.as_deref().and_then(validated_session_id) else { - return Some(ResumePlan::Hold(NO_SESSION_HOLD)); - }; - Some(ResumePlan::Relaunch(vec![RESUME_FLAG.to_string(), id])) - } -} - -/// Map a hook `error_type` to a kind and a fixed detail string. -/// -/// `None` means "not a limit, nothing to recover". The detail is a literal, not -/// the payload's `error_message`, which can carry account and quota text. -fn classify(error_type: &str) -> Option<(LimitKind, &'static str)> { - match error_type { - "rate_limit" => Some((LimitKind::UsageLimit, "claude api error: rate_limit")), - "overloaded" => Some((LimitKind::Transient, "claude api error: overloaded")), - "server_error" => Some((LimitKind::Transient, "claude api error: server_error")), - "authentication_failed" => Some(( - LimitKind::NeedsHuman, - "claude api error: authentication_failed", - )), - "oauth_org_not_allowed" => Some(( - LimitKind::NeedsHuman, - "claude api error: oauth_org_not_allowed", - )), - "billing_error" => Some((LimitKind::NeedsHuman, "claude api error: billing_error")), - _ => None, - } -} - -/// Accept a session id only if it is safe to hand back as a command-line -/// argument: non-empty, bounded, and made of ASCII alphanumerics, `-`, or `_`. -fn validated_session_id(raw: &str) -> Option { - if raw.is_empty() || raw.len() > MAX_SESSION_ID_BYTES { - return None; - } - if !raw - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - { - return None; - } - Some(raw.to_string()) -} - -#[cfg(test)] -#[path = "claude_tests.rs"] -mod tests; - -#[cfg(test)] -#[path = "claude_output_tests.rs"] -mod output_tests; diff --git a/plugins/nightcrow-recovery/src/provider/claude_output_tests.rs b/plugins/nightcrow-recovery/src/provider/claude_output_tests.rs deleted file mode 100644 index 710dbcae..00000000 --- a/plugins/nightcrow-recovery/src/provider/claude_output_tests.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! The terminal-output fallback and the resume plan, sharing the fixtures in -//! `claude_tests`. Split from that file only to stay inside the 300-line limit. - -use super::tests::{NOW, SESSION, ctx}; -use super::*; - -#[test] -fn a_usage_limit_line_in_output_reports_a_limit_exactly_once() { - let mut claude = Claude::default(); - let text = "Claude usage limit reached. Your limit will reset later.\n"; - let event = claude - .on_output(&ctx(1), text, NOW) - .expect("a blocked account"); - assert_eq!(event.kind, LimitKind::UsageLimit); - assert_eq!( - event.resets_at, None, - "no offset is printed, so no deadline" - ); - assert_eq!(event.session_id, None); - assert_eq!( - claude.on_output(&ctx(1), text, NOW), - None, - "redraw must not refire" - ); -} - -#[test] -fn output_matching_ignores_case() { - let mut claude = Claude::default(); - let shouted = "YOU'VE HIT YOUR USAGE LIMIT"; - assert!(claude.on_output(&ctx(1), shouted, NOW).is_some()); -} - -#[test] -fn a_warning_that_the_limit_is_approaching_does_not_report_a_limit() { - let mut claude = Claude::default(); - let text = "Heads up: you are approaching your usage limit for this window.\n"; - assert_eq!(claude.on_output(&ctx(1), text, NOW), None); -} - -#[test] -fn a_needle_split_across_two_output_chunks_is_still_found() { - let mut claude = Claude::default(); - assert_eq!(claude.on_output(&ctx(1), "Claude usage li", NOW), None); - let event = claude.on_output(&ctx(1), "mit reached\n", NOW); - assert!(event.is_some(), "the tail must span the chunk boundary"); -} - -#[test] -fn output_older_than_the_tail_budget_is_dropped() { - let mut claude = Claude::default(); - assert_eq!(claude.on_output(&ctx(1), "usage li", NOW), None); - let filler = "-".repeat(OUTPUT_TAIL_BYTES); - assert_eq!(claude.on_output(&ctx(1), &filler, NOW), None); - assert_eq!(claude.on_output(&ctx(1), "mit reached", NOW), None); -} - -#[test] -fn an_exit_or_a_generation_change_rearms_the_output_latch() { - let mut claude = Claude::default(); - let text = "Claude usage limit reached\n"; - assert!(claude.on_output(&ctx(1), text, NOW).is_some()); - assert_eq!(claude.on_output(&ctx(1), text, NOW), None); - claude.on_exit(&ctx(1)); - assert!( - claude.on_output(&ctx(1), text, NOW).is_some(), - "exit re-arms" - ); - assert!( - claude.on_output(&ctx(2), text, NOW).is_some(), - "respawn re-arms" - ); -} - -#[test] -fn a_live_pane_is_resumed_by_typing_one_continuation_line() { - let claude = Claude::default(); - let limit = LimitEvent::usage(Some(SESSION.to_string()), None, "d"); - let plan = claude.resume(&ctx(1), &limit, true); - assert_eq!(plan, Some(ResumePlan::Input(NUDGE_INPUT.to_string()))); - assert!(NUDGE_INPUT.ends_with('\r')); -} - -#[test] -fn an_exited_pane_relaunches_only_when_the_session_id_is_known() { - let claude = Claude::default(); - let with_id = LimitEvent::usage(Some(SESSION.to_string()), None, "d"); - let expected = vec![RESUME_FLAG.to_string(), SESSION.to_string()]; - let plan = claude.resume(&ctx(1), &with_id, false); - assert_eq!(plan, Some(ResumePlan::Relaunch(expected))); - let without_id = LimitEvent::usage(None, None, "d"); - let plan = claude.resume(&ctx(1), &without_id, false); - assert_eq!(plan, Some(ResumePlan::Hold(NO_SESSION_HOLD))); -} - -#[test] -fn a_needs_human_limit_holds_even_while_the_pane_is_alive() { - let claude = Claude::default(); - let limit = LimitEvent { - session_id: Some(SESSION.to_string()), - resets_at: None, - kind: LimitKind::NeedsHuman, - detail: "d".to_string(), - }; - let hold = Some(ResumePlan::Hold(NEEDS_HUMAN_HOLD)); - assert_eq!(claude.resume(&ctx(1), &limit, true), hold); - assert_eq!(claude.resume(&ctx(1), &limit, false), hold); -} diff --git a/plugins/nightcrow-recovery/src/provider/claude_tests.rs b/plugins/nightcrow-recovery/src/provider/claude_tests.rs deleted file mode 100644 index 329b5631..00000000 --- a/plugins/nightcrow-recovery/src/provider/claude_tests.rs +++ /dev/null @@ -1,222 +0,0 @@ -use super::*; -use serde_json::json; - -/// Feb 2025, comfortably inside the plausible band the shared helpers enforce. -pub(super) const NOW: i64 = 1_738_400_000; -const FIVE_HOUR_RESET: i64 = 1_738_425_600; -const SEVEN_DAY_RESET: i64 = 1_738_857_600; -pub(super) const SESSION: &str = "0199f0aa-1111-4222-8333-abcdef123456"; - -pub(super) fn ctx(generation: PaneGeneration) -> PaneContext { - PaneContext { - token: "pane0".to_string(), - generation, - cwd: "/repo".to_string(), - command: Some("claude".to_string()), - } -} - -fn rate_limits(payload: Value) -> OutOfBand { - OutOfBand { - kind: SignalKind::RateLimits, - payload, - } -} - -fn stop_failure(payload: Value) -> OutOfBand { - OutOfBand { - kind: SignalKind::StopFailure, - payload, - } -} - -/// A realistic hook payload, `error_message` included so tests can prove it never -/// reaches a `detail`. -fn stop_failure_of(error_type: &str) -> OutOfBand { - stop_failure(json!({ - "hook_event_name": "StopFailure", - "session_id": SESSION, - "transcript_path": "/home/u/.claude/t.jsonl", - "cwd": "/repo", - "error_type": error_type, - "error_message": "Quota exceeded for account acct_secret_42", - })) -} - -/// Feed one `rate_limits` object and report the deadline it left behind, which a -/// later StopFailure would carry. -fn deadline_after(payload: Value) -> Option { - let mut claude = Claude::default(); - assert_eq!(claude.on_signal(&ctx(1), &rate_limits(payload), NOW), None); - claude - .on_signal(&ctx(1), &stop_failure_of("rate_limit"), NOW) - .expect("rate_limit is a limit") - .resets_at -} - -fn window(resets_at: Value) -> Value { - json!({"five_hour": {"used_percentage": 99.0, "resets_at": resets_at}}) -} - -#[test] -fn the_adapter_reports_its_stable_name() { - assert_eq!(Claude::default().name(), "claude"); -} - -#[test] -fn both_rate_limit_windows_present_picks_the_earliest_reset() { - let payload = json!({ - "five_hour": {"used_percentage": 23.5, "resets_at": FIVE_HOUR_RESET}, - "seven_day": {"used_percentage": 41.2, "resets_at": SEVEN_DAY_RESET}, - }); - assert_eq!(deadline_after(payload), Some(FIVE_HOUR_RESET)); -} - -#[test] -fn a_single_rate_limit_window_is_used_as_the_deadline() { - let payload = json!({"seven_day": {"used_percentage": 41.2, "resets_at": SEVEN_DAY_RESET}}); - assert_eq!(deadline_after(payload), Some(SEVEN_DAY_RESET)); -} - -/// The object is absent for non-Pro/Max accounts and empty before the session's -/// first response; a window may also arrive without its `resets_at`. -#[test] -fn an_absent_or_incomplete_rate_limits_object_yields_no_deadline() { - for payload in [json!({}), json!({"five_hour": {"used_percentage": 12.0}})] { - assert_eq!(deadline_after(payload.clone()), None, "{payload:?}"); - } -} - -/// Null, wrong type, non-positive, and beyond the believable horizon must all -/// degrade to "no deadline" rather than to a wait of the wrong length. -#[test] -fn an_unusable_resets_at_yields_no_deadline() { - let far = NOW + crate::provider::MAX_RESET_HORIZON_SECS + 1; - let bad = [ - Value::Null, - json!("1738425600"), - json!(-1), - json!(0), - json!(far), - json!(1.5), - ]; - for value in bad { - assert_eq!(deadline_after(window(value.clone())), None, "{value:?}"); - } -} - -#[test] -fn a_rate_limits_signal_alone_is_never_a_limit_even_at_a_full_window() { - let payload = json!({ - "five_hour": {"used_percentage": 100.0, "resets_at": FIVE_HOUR_RESET}, - "seven_day": {"used_percentage": 100.0, "resets_at": SEVEN_DAY_RESET}, - }); - let mut claude = Claude::default(); - assert_eq!(claude.on_signal(&ctx(1), &rate_limits(payload), NOW), None); -} - -#[test] -fn a_stop_failure_with_error_type_rate_limit_reports_a_usage_limit() { - let mut claude = Claude::default(); - let event = claude - .on_signal(&ctx(1), &stop_failure_of("rate_limit"), NOW) - .expect("rate_limit is a usage limit"); - assert_eq!(event.kind, LimitKind::UsageLimit); - assert_eq!(event.session_id.as_deref(), Some(SESSION)); - assert_eq!(event.resets_at, None); -} - -/// `None` means "not a limit": no wait and no retry can fix it, so the machine -/// must be told nothing at all rather than told to back off. -#[test] -fn every_documented_error_type_maps_to_its_own_kind() { - let cases = [ - ("rate_limit", Some(LimitKind::UsageLimit)), - ("overloaded", Some(LimitKind::Transient)), - ("server_error", Some(LimitKind::Transient)), - ("authentication_failed", Some(LimitKind::NeedsHuman)), - ("oauth_org_not_allowed", Some(LimitKind::NeedsHuman)), - ("billing_error", Some(LimitKind::NeedsHuman)), - ("invalid_request", None), - ("model_not_found", None), - ("max_output_tokens", None), - ("unknown", None), - ("an_error_type_from_a_future_release", None), - ]; - for (error_type, want) in cases { - let mut claude = Claude::default(); - let got = claude - .on_signal(&ctx(1), &stop_failure_of(error_type), NOW) - .map(|event| event.kind); - assert_eq!(got, want, "{error_type}"); - } -} - -#[test] -fn a_stop_failure_without_an_error_type_reports_nothing() { - let mut claude = Claude::default(); - let signal = stop_failure(json!({"hook_event_name": "StopFailure", "session_id": SESSION})); - assert_eq!(claude.on_signal(&ctx(1), &signal, NOW), None); -} - -#[test] -fn a_payload_naming_a_different_hook_event_is_ignored() { - let mut claude = Claude::default(); - let signal = stop_failure(json!({ - "hook_event_name": "Stop", - "session_id": SESSION, - "error_type": "rate_limit", - })); - assert_eq!(claude.on_signal(&ctx(1), &signal, NOW), None); -} - -#[test] -fn a_stop_failure_detail_never_repeats_the_error_message() { - let mut claude = Claude::default(); - let event = claude - .on_signal(&ctx(1), &stop_failure_of("rate_limit"), NOW) - .expect("rate_limit is a limit"); - assert!(!event.detail.contains("acct_secret_42"), "{}", event.detail); - assert!(event.detail.contains("rate_limit"), "{}", event.detail); -} - -#[test] -fn a_stop_failure_prefers_the_remembered_statusline_reset() { - let mut claude = Claude::default(); - let payload = json!({"five_hour": {"resets_at": FIVE_HOUR_RESET}}); - assert_eq!(claude.on_signal(&ctx(1), &rate_limits(payload), NOW), None); - let event = claude - .on_signal(&ctx(1), &stop_failure_of("rate_limit"), NOW) - .expect("rate_limit is a limit"); - assert_eq!(event.resets_at, Some(FIVE_HOUR_RESET)); -} - -/// Still a limit, just not resumable: a rejected id must leave the event without -/// one so the machine holds rather than resuming some other session. -#[test] -fn a_session_id_that_is_absent_or_unsafe_as_an_argument_is_rejected() { - let over_long = json!("x".repeat(MAX_SESSION_ID_BYTES + 1)); - let bad = [ - None, - Some(Value::Null), - Some(json!("")), - Some(json!("abc; rm -rf /")), - Some(json!("abc def")), - Some(json!("abc$(id)")), - Some(json!(42)), - Some(over_long), - ]; - for id in bad { - let mut payload = json!({"hook_event_name": "StopFailure", "error_type": "rate_limit"}); - if let Some(id) = id.clone() { - payload["session_id"] = id; - } - let mut claude = Claude::default(); - let event = claude - .on_signal(&ctx(1), &stop_failure(payload), NOW) - .expect("a rate limit is still reported"); - assert_eq!(event.session_id, None, "{id:?}"); - let plan = claude.resume(&ctx(1), &event, false); - assert_eq!(plan, Some(ResumePlan::Hold(NO_SESSION_HOLD)), "{id:?}"); - } -} diff --git a/plugins/nightcrow-recovery/src/provider/codex_output_tests.rs b/plugins/nightcrow-recovery/src/provider/codex_output_tests.rs index 19c92816..d62292ca 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_output_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_output_tests.rs @@ -6,7 +6,6 @@ use super::pane::{OUTPUT_DETAIL, USAGE_LIMIT_NEEDLES}; use super::rollout::USAGE_LIMIT_ERROR_INFO; use super::*; use crate::protocol::PaneGeneration; -use crate::provider::LimitKind; const UUID: &str = "0199cbb1-2b70-7f11-9f0f-0f8e9d1c2b3a"; /// Any plausible "now"; the output path never reads a time out of text, so the @@ -42,7 +41,6 @@ fn every_terminal_output_needle_fires_once() { let event = codex .on_output(&ctx(1), &shouted, NOW) .expect("the needle fires"); - assert_eq!(event.kind, LimitKind::UsageLimit); assert_eq!(event.detail, OUTPUT_DETAIL); assert_eq!(event.resets_at, None, "no time is ever read out of text"); assert_eq!(event.session_id, None); diff --git a/plugins/nightcrow-recovery/src/provider/codex_pane.rs b/plugins/nightcrow-recovery/src/provider/codex_pane.rs index 87d087b2..663c50a3 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_pane.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_pane.rs @@ -55,11 +55,6 @@ pub(super) struct PaneState { pending: Vec, session_id: Option, resets_at: Option, - /// Which window codex reported as reached. Parsed because the record is - /// seen only once, but kept out of `detail`, which carries - /// `codex_error_info` alone so no other provider-side string can widen - /// what this plugin says. - reached_type: Option, output_tail: String, output_latched: bool, } @@ -75,7 +70,6 @@ impl PaneState { pending: Vec::new(), session_id: None, resets_at: None, - reached_type: None, output_tail: String::new(), output_latched: false, } @@ -174,16 +168,10 @@ impl PaneState { } None } - Record::TokenCount { - resets_at, - reached_type, - } => { + Record::TokenCount { resets_at } => { if resets_at.is_some() { self.resets_at = resets_at; } - if reached_type.is_some() { - self.reached_type = reached_type; - } None } Record::UsageLimit => Some(LimitEvent::usage( diff --git a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs index 9a83cdf4..abbd97c3 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_rollout.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_rollout.rs @@ -21,10 +21,6 @@ pub const MAX_RECORD_BYTES: usize = 64 * 1024; /// format, and the cap exists because the id becomes a command-line argument. const MAX_SESSION_ID_BYTES: usize = 128; -/// Longest remembered `rate_limit_reached_type`. It is a provider enum name, so -/// anything longer is not one and is not worth keeping. -const MAX_REACHED_TYPE_BYTES: usize = 64; - /// The one `codex_error_info` value that means "usage limit". Any other value is /// a different failure mode that waiting cannot fix, so it is not ours. pub const USAGE_LIMIT_ERROR_INFO: &str = "usage_limit_exceeded"; @@ -51,10 +47,7 @@ pub enum Record { SessionMeta { id: Option }, /// A usage snapshot. `resets_at` is already validated as a plausible /// absolute unix second, or `None`. - TokenCount { - resets_at: Option, - reached_type: Option, - }, + TokenCount { resets_at: Option }, /// A turn that ended because the usage limit was hit. UsageLimit, } @@ -78,7 +71,6 @@ pub fn classify_line(line: &str, now_epoch: i64) -> Option { let payload = value.get("payload")?; Some(Record::TokenCount { resets_at: reset_epoch_from_json(payload, &RESETS_AT_PATH, now_epoch), - reached_type: reached_type_from_payload(payload), }) } "turn_complete" => { @@ -102,19 +94,6 @@ fn session_id_from_payload(payload: &Value) -> Option { .map(str::to_string) } -/// Which rate-limit window codex says was reached, when it is a short plain -/// string. A long or non-ASCII value is not an enum name and is dropped. -fn reached_type_from_payload(payload: &Value) -> Option { - let raw = payload - .get("rate_limits")? - .get("rate_limit_reached_type")? - .as_str()?; - let ok = !raw.is_empty() - && raw.len() <= MAX_REACHED_TYPE_BYTES - && raw.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'); - ok.then(|| raw.to_string()) -} - /// Whether an id is safe to hand back as a command-line argument. /// /// A session id reaches the host as `codex resume `, so it must not be diff --git a/plugins/nightcrow-recovery/src/provider/codex_rollout_tests.rs b/plugins/nightcrow-recovery/src/provider/codex_rollout_tests.rs index d5c6d6b0..4fd751b1 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_rollout_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_rollout_tests.rs @@ -23,7 +23,7 @@ fn deadline_for(payload: Value) -> Option { } fn rate_limits(primary: Value) -> Value { - json!({"rate_limits": {"primary": primary, "rate_limit_reached_type": "primary"}}) + json!({"rate_limits": {"primary": primary}}) } #[test] @@ -111,18 +111,6 @@ fn a_token_count_with_a_valid_resets_at_is_accepted() { ); } -#[test] -fn a_token_count_remembers_a_short_rate_limit_reached_type() { - let payload = rate_limits(json!({"resets_at": SOON})); - assert_eq!( - classify_line(&record("token_count", payload), NOW), - Some(Record::TokenCount { - resets_at: Some(SOON), - reached_type: Some("primary".to_string()), - }) - ); -} - #[test] fn a_resets_at_that_is_missing_is_rejected() { assert_eq!(deadline_for(rate_limits(json!({"used_percent": 90}))), None); diff --git a/plugins/nightcrow-recovery/src/provider/codex_tests.rs b/plugins/nightcrow-recovery/src/provider/codex_tests.rs index 4d3020ec..865ae38e 100644 --- a/plugins/nightcrow-recovery/src/provider/codex_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/codex_tests.rs @@ -7,7 +7,6 @@ use super::rollout::USAGE_LIMIT_ERROR_INFO; use super::*; use crate::protocol::PaneGeneration; -use crate::provider::LimitKind; use serde_json::{Value, json}; use std::io::Write as _; use std::path::Path; @@ -63,7 +62,7 @@ fn token_count(resets_at: Value) -> String { } fn rate_limits(primary: Value) -> Value { - json!({"rate_limits": {"primary": primary, "rate_limit_reached_type": "primary"}}) + json!({"rate_limits": {"primary": primary}}) } fn limit_turn() -> String { @@ -142,7 +141,6 @@ fn a_malformed_line_is_skipped_and_later_records_still_parse() { let (_home, event) = poll_once(&lines); let event = event.expect("a usage limit event"); assert_eq!(event.resets_at, Some(reset)); - assert_eq!(event.kind, LimitKind::UsageLimit); assert_eq!(event.detail, USAGE_LIMIT_ERROR_INFO); assert_eq!(event.session_id.as_deref(), Some(UUID_A)); } diff --git a/plugins/nightcrow-recovery/src/provider/mod.rs b/plugins/nightcrow-recovery/src/provider/mod.rs index 67f197b8..f4f7a5aa 100644 --- a/plugins/nightcrow-recovery/src/provider/mod.rs +++ b/plugins/nightcrow-recovery/src/provider/mod.rs @@ -3,14 +3,13 @@ //! The state machine knows nothing about any particular CLI: it asks an adapter //! "did this pane just hit a usage limit, and when does that limit reset", and //! later "how do I get this session going again". Everything provider-specific — -//! which hook fires, which file to tail, which flag resumes a session — lives in +//! which file to tail, which flag resumes a session — lives in //! one file per adapter. use crate::protocol::{PaneGeneration, PaneToken}; use serde_json::Value; use std::path::Path; -pub mod claude; pub mod codex; pub mod opencode; @@ -23,19 +22,6 @@ pub struct PaneContext { pub command: Option, } -/// Why a provider stopped. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LimitKind { - /// A plan/usage limit that clears at a known or estimated time. Worth - /// waiting for. - UsageLimit, - /// An overload or server error. Worth a short backoff, not a long wait. - Transient, - /// Auth, billing, or a bad request. No amount of waiting fixes it, so the - /// machine stops and says so instead of retrying. - NeedsHuman, -} - /// An adapter's report that a pane's provider stopped for a limit-like reason. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LimitEvent { @@ -46,7 +32,6 @@ pub struct LimitEvent { /// Absolute unix seconds at which the limit clears, when the provider said /// so. `None` means the machine must fall back to bounded backoff. pub resets_at: Option, - pub kind: LimitKind, /// Short, non-sensitive explanation for the host's status line. Never /// carries transcript text or a raw payload. pub detail: String, @@ -57,59 +42,17 @@ impl LimitEvent { Self { session_id, resets_at, - kind: LimitKind::UsageLimit, detail: detail.to_string(), } } } -/// A signal that did not come through the pane's terminal output. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SignalKind { - /// Claude Code's `StopFailure` hook payload. - StopFailure, - /// The `rate_limits` object from Claude Code's statusline payload. - RateLimits, - /// Claude Code's `Stop` hook: a turn ended, however it ended. Carries no - /// payload and never reaches a provider, because wanting the person back - /// is not a provider question. - TurnEnd, -} - -impl SignalKind { - /// The wire name used on the IPC socket. Anything else is rejected there. - pub fn as_wire(&self) -> &'static str { - match self { - Self::StopFailure => "stop_failure", - Self::RateLimits => "rate_limits", - Self::TurnEnd => "turn_end", - } - } - - pub fn from_wire(s: &str) -> Option { - match s { - "stop_failure" => Some(Self::StopFailure), - "rate_limits" => Some(Self::RateLimits), - "turn_end" => Some(Self::TurnEnd), - _ => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OutOfBand { - pub kind: SignalKind, - pub payload: Value, -} - /// How to get a stopped session going again. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResumePlan { /// Append these args to the pane's original command. The host supplies the /// program, so this can never name a different binary. Relaunch(Vec), - /// Type this into a pane whose process is still alive and idle. - Input(String), /// The adapter knows this pane must not be touched yet, and why. Hold(&'static str), } @@ -130,16 +73,6 @@ pub trait Provider { None } - /// A signal that arrived over the IPC socket. - fn on_signal( - &mut self, - _ctx: &PaneContext, - _signal: &OutOfBand, - _now_epoch: i64, - ) -> Option { - None - } - /// Called on the plugin's timer, for an adapter that has to look somewhere /// itself (a rollout file, an HTTP endpoint). fn poll(&mut self, _ctx: &PaneContext, _now_epoch: i64) -> Option { @@ -152,8 +85,7 @@ pub trait Provider { /// How to resume, or `None` when this adapter cannot say safely. /// - /// `alive` is the host's word that the pane's process is still running; an - /// adapter must only answer [`ResumePlan::Input`] when it is true. + /// `alive` is the host's word that the pane's process is still running. fn resume(&self, ctx: &PaneContext, limit: &LimitEvent, alive: bool) -> Option; } @@ -171,31 +103,12 @@ pub fn detect(command: Option<&str>) -> Option> { .find_map(|suffix| program.strip_suffix(suffix)) .unwrap_or(&program); match program { - "claude" => Some(Box::new(claude::Claude::default())), "codex" => Some(Box::new(codex::Codex::default())), "opencode" => Some(Box::new(opencode::OpenCode::default())), _ => None, } } -/// Pick an adapter from a signal that arrived over the IPC socket, for a pane -/// whose command line says nothing — the shell somebody opened and then -/// started a provider CLI inside by hand. -/// -/// Sound because a [`SignalKind`] is minted by exactly one provider's helper: -/// a `stop_failure` line can only have come from the Claude Code hook this -/// binary installed. The signal is therefore evidence of what the pane is -/// running, in a way terminal text never is — which is why this is a lookup on -/// the wire kind and deliberately not a second sniffing path. A kind added -/// later has to be classified here rather than falling through to a guess. -pub fn detect_from_signal(kind: SignalKind) -> Option> { - match kind { - SignalKind::StopFailure | SignalKind::RateLimits | SignalKind::TurnEnd => { - Some(Box::new(claude::Claude::default())) - } - } -} - /// The command's first shell word, including a quoted executable path. fn first_word(command: &str) -> Option<&str> { let command = command.trim_start(); @@ -215,9 +128,8 @@ fn first_word(command: &str) -> Option<&str> { /// Furthest ahead a reported reset time is believed. /// -/// Claude's longest documented window is seven days, so anything beyond eight is -/// either a different unit or a corrupt value; treating it as absent keeps a -/// bogus number from parking a pane for months. +/// Anything beyond eight days is treated as a different unit or corrupt value, +/// keeping a bogus number from parking a pane for months. pub const MAX_RESET_HORIZON_SECS: i64 = 8 * 24 * 60 * 60; /// Earliest plausible unix second for a reset time (2020-01-01). Below this a diff --git a/plugins/nightcrow-recovery/src/provider/mod_tests.rs b/plugins/nightcrow-recovery/src/provider/mod_tests.rs index 29550197..6228f7e6 100644 --- a/plugins/nightcrow-recovery/src/provider/mod_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/mod_tests.rs @@ -5,21 +5,21 @@ const NOW: i64 = 1_767_225_600; #[test] fn a_reset_time_in_the_documented_shape_is_read_as_unix_seconds() { - let payload = serde_json::json!({"five_hour": {"resets_at": NOW + 3600}}); + let payload = serde_json::json!({"primary": {"resets_at": NOW + 3600}}); assert_eq!( - reset_epoch_from_json(&payload, &["five_hour", "resets_at"], NOW), + reset_epoch_from_json(&payload, &["primary", "resets_at"], NOW), Some(NOW + 3600) ); } #[test] fn a_reset_time_that_is_absent_is_not_invented() { - let payload = serde_json::json!({"five_hour": {}}); + let payload = serde_json::json!({"primary": {}}); assert_eq!( - reset_epoch_from_json(&payload, &["five_hour", "resets_at"], NOW), + reset_epoch_from_json(&payload, &["primary", "resets_at"], NOW), None ); - assert_eq!(reset_epoch_from_json(&payload, &["seven_day"], NOW), None); + assert_eq!(reset_epoch_from_json(&payload, &["secondary"], NOW), None); } #[test] @@ -63,9 +63,6 @@ fn a_reset_time_already_in_the_past_is_still_a_reset_time() { #[test] fn each_known_provider_is_recognised_from_its_command_line() { for (command, name) in [ - ("claude", "claude"), - ("claude --model opus", "claude"), - ("/usr/local/bin/claude", "claude"), ("codex", "codex"), ("codex resume --last", "codex"), ("opencode", "opencode"), @@ -80,7 +77,6 @@ fn each_known_provider_is_recognised_from_its_command_line() { #[test] fn windows_executable_paths_and_wrapper_shims_are_recognised() { for (command, name) in [ - (r"C:\Tools\claude.exe --model opus", "claude"), (r#""C:\Program Files\OpenAI\codex.cmd" resume"#, "codex"), (r"C:\Tools\opencode.PS1", "opencode"), ] { @@ -97,9 +93,9 @@ fn a_pane_running_something_else_is_not_watched_at_all() { Some(" "), Some("bash"), Some("zsh -l"), - Some("claudette"), - Some(r#""C:\Tools\claude.exe"suffix"#), - Some(r#""C:\Tools\claude.exe"#), + Some("aider"), + Some(r#""C:\Tools\codex.exe"suffix"#), + Some(r#""C:\Tools\codex.exe"#), ] { assert!( detect(command).is_none(), @@ -107,32 +103,3 @@ fn a_pane_running_something_else_is_not_watched_at_all() { ); } } - -#[test] -fn every_signal_kind_names_the_adapter_whose_helper_minted_it() { - // A pane with no command line of its own — the shell somebody typed `claude` - // into — has only the signal to go on, and the signal's kind is enough: each - // one is written by exactly one provider's helper. - for kind in [SignalKind::StopFailure, SignalKind::RateLimits] { - let provider = - detect_from_signal(kind).unwrap_or_else(|| panic!("{kind:?} names an adapter")); - assert_eq!(provider.name(), "claude"); - } -} - -#[test] -fn a_signal_binds_an_adapter_where_the_command_line_cannot() { - // The pair that makes the late-adoption path work at all: `detect` gives up - // on a pane with no command, and the signal is what answers instead. - assert!(detect(None).is_none()); - assert!(detect_from_signal(SignalKind::StopFailure).is_some()); -} - -#[test] -fn a_signal_kind_round_trips_through_its_wire_name() { - for kind in [SignalKind::StopFailure, SignalKind::RateLimits] { - assert_eq!(SignalKind::from_wire(kind.as_wire()), Some(kind)); - } - assert_eq!(SignalKind::from_wire("transcript"), None); - assert_eq!(SignalKind::from_wire(""), None); -} diff --git a/plugins/nightcrow-recovery/src/provider/opencode.rs b/plugins/nightcrow-recovery/src/provider/opencode.rs index b15933bf..807231d2 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode.rs @@ -139,9 +139,7 @@ impl OpenCode { fn remember_retry(&mut self, status: &SessionStatus, now_epoch: i64) { let resets_at = match status.kind { - StatusKind::Retry { - next: Some(next), .. - } => interpret_next(next, now_epoch), + StatusKind::Retry { next: Some(next) } => interpret_next(next, now_epoch), _ => None, }; let known = self.retrying.get_or_insert_default(); @@ -239,8 +237,8 @@ impl Provider for OpenCode { } fn resume(&self, _ctx: &PaneContext, limit: &LimitEvent, alive: bool) -> Option { - // Never `ResumePlan::Input`: a live pane may be mid-retry, and typing at - // one is exactly what this adapter exists to avoid. + // A live pane may be mid-retry, and typing at one is exactly what this + // adapter exists to avoid. if alive { return Some(ResumePlan::Hold(ALIVE_HOLD)); } diff --git a/plugins/nightcrow-recovery/src/provider/opencode_http.rs b/plugins/nightcrow-recovery/src/provider/opencode_http.rs index b2449643..ca015d95 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode_http.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode_http.rs @@ -52,7 +52,6 @@ pub struct SessionStatus { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusKind { Retry { - attempt: u32, next: Option, }, Busy, @@ -107,13 +106,6 @@ fn status_object(entry: &Value) -> Option<&Value> { fn status_kind(status: &Value) -> StatusKind { match status.get("type").and_then(Value::as_str) { Some("retry") => StatusKind::Retry { - // Informational only — nothing is decided from the attempt number — - // so a missing one is not a parse failure. - attempt: status - .get("attempt") - .and_then(Value::as_u64) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0), next: status.get("next").and_then(Value::as_i64), }, Some("busy") => StatusKind::Busy, diff --git a/plugins/nightcrow-recovery/src/provider/opencode_http_tests.rs b/plugins/nightcrow-recovery/src/provider/opencode_http_tests.rs index d2167cfe..6c87f0e9 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode_http_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode_http_tests.rs @@ -71,15 +71,12 @@ fn a_relative_delay_is_accepted_up_to_the_horizon_and_refused_past_it() { #[test] fn an_object_keyed_by_session_id_yields_one_status_per_key() { - let statuses = parse_status_body(r#"{"ses_abc":{"type":"retry","attempt":3,"next":4000}}"#); + let statuses = parse_status_body(r#"{"ses_abc":{"type":"retry","next":4000}}"#); assert_eq!( statuses, vec![SessionStatus { session_id: Some("ses_abc".to_string()), - kind: StatusKind::Retry { - attempt: 3, - next: Some(4000) - }, + kind: StatusKind::Retry { next: Some(4000) }, }] ); } @@ -98,32 +95,8 @@ fn an_array_of_entries_yields_one_status_per_element() { #[test] fn a_retry_without_next_parses_with_no_deadline() { - let statuses = parse_status_body(r#"{"s":{"type":"retry","attempt":1}}"#); - assert_eq!( - statuses[0].kind, - StatusKind::Retry { - attempt: 1, - next: None - } - ); -} - -#[test] -fn a_retry_with_an_unusable_attempt_number_parses_as_attempt_zero() { - for body in [ - r#"{"s":{"type":"retry","attempt":-1}}"#, - r#"{"s":{"type":"retry","attempt":"many"}}"#, - r#"{"s":{"type":"retry"}}"#, - ] { - assert_eq!( - parse_status_body(body)[0].kind, - StatusKind::Retry { - attempt: 0, - next: None - }, - "body {body}" - ); - } + let statuses = parse_status_body(r#"{"s":{"type":"retry"}}"#); + assert_eq!(statuses[0].kind, StatusKind::Retry { next: None }); } #[test] diff --git a/plugins/nightcrow-recovery/src/provider/opencode_tests.rs b/plugins/nightcrow-recovery/src/provider/opencode_tests.rs index 78ec7d05..f2ed7d56 100644 --- a/plugins/nightcrow-recovery/src/provider/opencode_tests.rs +++ b/plugins/nightcrow-recovery/src/provider/opencode_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::provider::LimitKind; use serde_json::Value; /// Fixed "now", well inside the plausible epoch band. @@ -61,7 +60,7 @@ fn wrap(id: &str, status: Value) -> String { } fn retry_body(id: &str, next: Option) -> String { - let mut status = serde_json::json!({"type": "retry", "attempt": 2}); + let mut status = serde_json::json!({"type": "retry"}); if let Some(next) = next { status["next"] = next.into(); } @@ -95,7 +94,6 @@ fn a_retry_going_idle_produces_exactly_one_usage_limit_event() { let mut oc = adapter(vec![retry_body(SESSION, None), idle_body(SESSION)]); assert_eq!(oc.poll(&ctx(1), NOW), None); let event = oc.poll(&ctx(1), NOW + 5).expect("idle after retry reports"); - assert_eq!(event.kind, LimitKind::UsageLimit); assert_eq!(event.session_id.as_deref(), Some(SESSION)); assert_eq!(oc.poll(&ctx(1), NOW + 10), None, "second event suppressed"); } @@ -280,7 +278,7 @@ fn observe_command_ignores_a_port_it_cannot_use() { fn observe_command_leaves_the_port_alone_when_no_flag_is_present() { let mut oc = adapter(vec![]); let before = oc.port(); - oc.observe_command("opencode run --model anthropic/claude --print"); + oc.observe_command("opencode run --model openai/gpt-5 --print"); assert_eq!(oc.port(), before); assert_eq!(port_from_command("opencode"), None); } diff --git a/plugins/nightcrow-recovery/src/runloop.rs b/plugins/nightcrow-recovery/src/runloop.rs index 5a714632..cf809b03 100644 --- a/plugins/nightcrow-recovery/src/runloop.rs +++ b/plugins/nightcrow-recovery/src/runloop.rs @@ -1,19 +1,15 @@ -//! The plugin's main loop: NDJSON on stdin/stdout, plus the IPC socket. +//! The plugin's main loop: NDJSON on stdin/stdout plus a clock. //! -//! Three sources have to be watched at once — the host's stdin, the socket, and -//! a clock — and this process deliberately has no async runtime, so each of the -//! first two gets a thread that forwards into one channel and the main thread -//! blocks on that channel with a timeout. The timeout *is* the clock: every -//! wakeup, expired or not, ticks the state machines. +//! The host's stdin is read on one thread while the main thread blocks on its +//! channel with a timeout. The timeout *is* the clock: every wakeup, expired or +//! not, ticks the state machines. //! //! Everything the plugin says goes out from this one thread, so the NDJSON //! stream cannot interleave two half-written lines — see //! [`runloop_io`](crate::runloop_io), which owns both ends of that stream. -use crate::ipc::{Ipc, IpcMessage, socket_path}; -use crate::protocol::{LogLevel, PluginEvent, attention, log}; -use crate::provider::{OutOfBand, PaneContext, Provider, SignalKind, detect, detect_from_signal}; -use crate::runloop_adopt::Adoptions; +use crate::protocol::{LogLevel, PluginEvent, log}; +use crate::provider::{PaneContext, Provider, detect}; use crate::runloop_io::{Message, emit, emit_all, spawn_stdin_reader}; use crate::state::{PaneRecovery, RecoveryState}; use crate::wait::now_epoch; @@ -34,9 +30,7 @@ const TICK: Duration = Duration::from_secs(1); /// A session holds a handful of panes. The cap exists so a host that somehow /// announced panes without ever closing them cannot grow this process without /// bound; reaching it means dropping the *new* pane, which fails closed. It -/// binds the panes we asked for as well as the ones we were given — the ask is -/// bounded separately (see [`Adoptions`]), but this is the ceiling on what any -/// number of asks can add up to. +/// bounds every pane the host gives this plugin. const MAX_TRACKED_PANES: usize = 64; /// One tracked pane: its recovery progress and the adapter watching it. @@ -48,32 +42,9 @@ struct Watch { pub fn run() -> Result<()> { let (tx, rx) = channel::(); - // Bound the socket's lifetime to this function: dropping it unlinks the - // socket file, so a normal exit leaves nothing for the next run to clear. - let ipc = match Ipc::bind(socket_path()?) { - Ok(ipc) => Some(ipc), - Err(e) => { - // Without the socket the plugin still works from terminal output and - // from the providers it can poll, so this is degraded, not fatal. - emit(&log( - LogLevel::Warn, - format!("recovery ipc unavailable, falling back to output watching: {e}"), - ))?; - None - } - }; - if let Some(ipc) = &ipc { - emit(&log( - LogLevel::Debug, - format!("recovery ipc listening on {}", ipc.path().display()), - ))?; - let signals = tx.clone(); - ipc.serve(move |msg| signals.send(Message::Signal(msg)).is_ok())?; - } spawn_stdin_reader(tx); let mut panes: HashMap = HashMap::new(); - let mut adoptions = Adoptions::default(); emit(&log(LogLevel::Info, "nightcrow-recovery watching panes"))?; loop { let message = match rx.recv_timeout(TICK) { @@ -87,14 +58,12 @@ pub fn run() -> Result<()> { Some(Message::HostGone) | Some(Message::Host(PluginEvent::Shutdown { .. })) => { return farewell(&panes); } - Some(Message::Host(event)) => on_host_event(&mut panes, &mut adoptions, &event)?, + Some(Message::Host(event)) => on_host_event(&mut panes, &event)?, Some(Message::HostGarbage(reason)) => { emit(&log(LogLevel::Warn, reason))?; } - Some(Message::Signal(msg)) => on_signal(&mut panes, &mut adoptions, msg)?, None => {} } - adoptions.prune(Instant::now()); tick(&mut panes)?; } } @@ -119,19 +88,11 @@ fn farewell(panes: &HashMap) -> Result<()> { Ok(()) } -fn on_host_event( - panes: &mut HashMap, - adoptions: &mut Adoptions, - event: &PluginEvent, -) -> Result<()> { +fn on_host_event(panes: &mut HashMap, event: &PluginEvent) -> Result<()> { let Some(token) = event.token().cloned() else { return Ok(()); }; let now = now_epoch(); - // The signal that won this pane its watcher, held back until the pane has - // been through the housekeeping below — an adapter must not be asked about a - // pane whose first event has not been applied yet. - let mut held = None; if let PluginEvent::PaneOpened { generation, command, @@ -139,14 +100,7 @@ fn on_host_event( .. } = event { - held = open_pane( - panes, - adoptions, - &token, - *generation, - command.as_deref(), - cwd, - )?; + open_pane(panes, &token, *generation, command.as_deref(), cwd)?; } let Some(watch) = panes.get_mut(&token) else { return Ok(()); @@ -173,57 +127,42 @@ fn on_host_event( if matches!(event, PluginEvent::PaneClosed { .. }) { panes.remove(&token); } - if let Some(signal) = held { - deliver_signal(panes, &token, &signal)?; - } Ok(()) } -/// Start watching the pane `token` names, answering with the signal that has been -/// waiting for it — see [`Adoptions`]. +/// Start watching the pane `token` names when its configured command identifies +/// a provider this plugin supports. fn open_pane( panes: &mut HashMap, - adoptions: &mut Adoptions, token: &str, generation: u32, command: Option<&str>, cwd: &str, -) -> Result> { +) -> Result<()> { let ctx = PaneContext { token: token.to_string(), generation, cwd: cwd.to_string(), command: command.map(str::to_string), }; - // Claimed unconditionally: whether or not it decides the adapter below, a - // request that has been answered must not stay outstanding. - let claimed = adoptions.claim(token); if let Some(watch) = panes.get_mut(token) { // A relaunch reopens the same slot. The recovery state survives, so an // attempt budget cannot be reset by relaunching into the same limit. watch.ctx = ctx; - return Ok(claimed); + return Ok(()); } - // The command line first, since it is the host's own record of what the pane - // was launched as. Only when there is none does the signal decide, which is - // the pane somebody started a CLI in by hand: it says nothing about itself, - // but a provider's helper has just spoken for it. - let provider = detect(command).or_else(|| { - claimed - .as_ref() - .and_then(|signal| detect_from_signal(signal.kind)) - }); + let provider = detect(command); let Some(provider) = provider else { // A pane running something this plugin knows nothing about is not // watched at all, which is the cheapest way to stay out of it. - return Ok(None); + return Ok(()); }; if panes.len() >= MAX_TRACKED_PANES { emit(&log( LogLevel::Warn, format!("already watching {MAX_TRACKED_PANES} panes; not watching another"), ))?; - return Ok(None); + return Ok(()); } emit(&log( LogLevel::Info, @@ -237,48 +176,6 @@ fn open_pane( ctx, }, ); - Ok(claimed) -} - -fn on_signal( - panes: &mut HashMap, - adoptions: &mut Adoptions, - msg: IpcMessage, -) -> Result<()> { - if panes.contains_key(&msg.token) { - let (token, signal) = msg.into_signal(); - return deliver_signal(panes, &token, &signal); - } - // Not a pane we were told about — and that is the common case rather than - // the odd one. The token is proof the sender runs inside one of the host's - // panes, so ask for it; a token from another nightcrow session simply goes - // unanswered. See [`Adoptions`] for why asking is bounded. - if let Some(command) = adoptions.request(msg, Instant::now()) { - emit(&command)?; - } - Ok(()) -} - -/// Hand one out-of-band signal to the adapter watching `token`. -fn deliver_signal( - panes: &mut HashMap, - token: &str, - signal: &OutOfBand, -) -> Result<()> { - let Some(watch) = panes.get_mut(token) else { - return Ok(()); - }; - // Never reaches a provider: a turn ending says nothing about usage limits, - // and the recovery state machine has no opinion about it. It is passed - // straight through to the host as the one thing it means. - if signal.kind == SignalKind::TurnEnd { - return emit(&attention(token.to_string(), watch.ctx.generation)); - } - let now = now_epoch(); - if let Some(limit) = watch.provider.on_signal(&watch.ctx, signal, now) { - let commands = watch.recovery.note_limit(limit, now, Instant::now()); - emit_all(&commands)?; - } Ok(()) } diff --git a/plugins/nightcrow-recovery/src/runloop_adopt.rs b/plugins/nightcrow-recovery/src/runloop_adopt.rs deleted file mode 100644 index b665c296..00000000 --- a/plugins/nightcrow-recovery/src/runloop_adopt.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Asking the host for a pane it never named to us. -//! -//! The dominant way a coding CLI gets started is by hand: the user opens a -//! plain shell and types `claude` into it. That pane's `[[startup_command]]` -//! names no plugin, so the host never mentions it — but the CLI's hook still -//! reaches us over the socket, carrying the token the host put in that pane's -//! environment. Presenting the token back is the whole request; the host -//! decides whether to honour it, and never tells us when it does not. -//! -//! Everything here exists because of that silence: a refusal is -//! indistinguishable from a token belonging to another nightcrow session, so -//! an unanswered request must not be repeated in a tight loop and must not -//! leave state behind that grows with every stranger that knocks. - -use crate::ipc::IpcMessage; -use crate::protocol::{PaneToken, PluginCommand, watch_pane}; -use crate::provider::OutOfBand; -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -/// Most tokens we will have an outstanding request for at once. -/// -/// These are unsolicited: anything that can reach the socket can name a token we -/// have never seen. A session has a handful of panes and the host answers within -/// one of its ticks, so a backlog this deep already means the requests are not -/// being honoured — and dropping the newest is what keeps a stream of strangers -/// from growing this map. -const MAX_PENDING: usize = 8; - -/// How long one token's unanswered request suppresses another for that token. -/// -/// The host answers in milliseconds or never, so this is not a retry interval — -/// it is the rate at which a token that is not ours may cost us a command. -/// Claude Code's statusline runs on every render, so without it a foreign pane -/// would have us writing a refused request several times a second, each counted -/// against the host's per-tick command budget. Half a minute is far longer than -/// any honoured request takes and short enough that a pane which only just -/// became ours is not shut out for long. -const REQUEST_COOLDOWN: Duration = Duration::from_secs(30); - -/// One outstanding request, and the signal that justified making it. -struct Pending { - /// Kept so the adapter still gets it. The signal arrives *before* the pane - /// does — it is the reason the pane arrives at all — and the host replays - /// no history to a pane it has just handed over, so dropping it would lose - /// the very limit being recovered from. - signal: OutOfBand, - asked_at: Instant, -} - -/// The tokens we have asked about and not yet been given. -#[derive(Default)] -pub(crate) struct Adoptions(HashMap); - -impl Adoptions { - /// Turn a signal for an untracked pane into a request, or into nothing. - /// - /// Consumes the message either way: when the answer is nothing, the token and - /// its payload are dropped here rather than recorded, so a token that will - /// never be honoured costs one hash probe and no growth. - pub(crate) fn request(&mut self, msg: IpcMessage, now: Instant) -> Option { - let (token, signal) = msg.into_signal(); - // Already asked, and [`Self::prune`] has not yet given up on it. - if self.0.contains_key(&token) { - return None; - } - if self.0.len() >= MAX_PENDING { - return None; - } - self.0.insert( - token.clone(), - Pending { - signal, - asked_at: now, - }, - ); - Some(watch_pane(token)) - } - - /// Take back the signal that won `token` its request, now that the host has - /// described the pane. Answers `None` for a pane we never asked about, which - /// is every configured pane. - pub(crate) fn claim(&mut self, token: &str) -> Option { - self.0.remove(token).map(|pending| pending.signal) - } - - /// Give up on requests the host has not answered within - /// [`REQUEST_COOLDOWN`], which is also what lets that token be asked about - /// again. - /// - /// Without it a handful of foreign tokens would hold every slot for the - /// process's whole life. Giving up is safe: a pane that really is ours - /// signals again, and the held signal is stale by then anyway. - pub(crate) fn prune(&mut self, now: Instant) { - self.0 - .retain(|_, p| now.saturating_duration_since(p.asked_at) < REQUEST_COOLDOWN); - } -} - -#[cfg(test)] -#[path = "runloop_adopt_tests.rs"] -mod tests; diff --git a/plugins/nightcrow-recovery/src/runloop_adopt_tests.rs b/plugins/nightcrow-recovery/src/runloop_adopt_tests.rs deleted file mode 100644 index 0a1efae3..00000000 --- a/plugins/nightcrow-recovery/src/runloop_adopt_tests.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! Asking for a pane: what is asked once, what is not asked twice, and what a -//! stream of tokens we will never be given can cost this process. - -use super::*; -use crate::provider::SignalKind; - -fn signal(token: &str) -> IpcMessage { - IpcMessage { - token: token.to_string(), - kind: SignalKind::StopFailure, - payload: serde_json::json!({"error_type": "rate_limit"}), - } -} - -fn asked_token(command: &PluginCommand) -> String { - match command { - PluginCommand::WatchPane { token, .. } => token.clone(), - other => panic!("expected a watch_pane request, got {other:?}"), - } -} - -#[test] -fn a_signal_for_an_unknown_pane_asks_the_host_for_it_by_token() { - let mut a = Adoptions::default(); - let command = a - .request(signal("abc123"), Instant::now()) - .expect("a first signal asks"); - assert_eq!(asked_token(&command), "abc123"); -} - -#[test] -fn a_second_signal_for_the_same_token_asks_nothing_more() { - // The host answers in milliseconds or never, so a repeat inside the cooldown - // could only be noise — and Claude Code's statusline is noisy: it runs on - // every render. - let mut a = Adoptions::default(); - let now = Instant::now(); - assert!(a.request(signal("abc123"), now).is_some()); - for _ in 0..50 { - assert!(a.request(signal("abc123"), now).is_none()); - } -} - -#[test] -fn a_token_may_be_asked_about_again_once_its_request_has_been_given_up_on() { - let mut a = Adoptions::default(); - let now = Instant::now(); - assert!(a.request(signal("abc123"), now).is_some()); - - a.prune(now + REQUEST_COOLDOWN); - - assert!( - a.request(signal("abc123"), now + REQUEST_COOLDOWN) - .is_some(), - "a pane that only just became ours must not be shut out for good" - ); -} - -#[test] -fn a_request_still_inside_its_cooldown_survives_pruning() { - let mut a = Adoptions::default(); - let now = Instant::now(); - assert!(a.request(signal("abc123"), now).is_some()); - - a.prune(now + REQUEST_COOLDOWN / 2); - - assert!( - a.request(signal("abc123"), now + REQUEST_COOLDOWN / 2) - .is_none() - ); -} - -#[test] -fn a_flood_of_unknown_tokens_stops_at_the_pending_ceiling() { - // Unsolicited state: anything that can reach the socket can name a token we - // have never seen, so this must stop growing rather than stop working. - let mut a = Adoptions::default(); - let now = Instant::now(); - for i in 0..MAX_PENDING { - assert!( - a.request(signal(&format!("token{i}")), now).is_some(), - "the first {MAX_PENDING} tokens are asked about" - ); - } - assert!( - a.request(signal("onemore"), now).is_none(), - "past the ceiling a new token is dropped, not queued" - ); - // And the dropped token left nothing behind, so the ceiling is a ceiling on - // memory and not merely on requests. - a.prune(now + REQUEST_COOLDOWN); - assert!( - a.request(signal("onemore"), now + REQUEST_COOLDOWN) - .is_some() - ); -} - -#[test] -fn the_signal_that_won_a_pane_its_request_is_handed_back_when_the_pane_arrives() { - // The signal arrives before the pane does and the host replays no history, so - // losing it here would lose the very limit the recovery is about. - let mut a = Adoptions::default(); - assert!(a.request(signal("abc123"), Instant::now()).is_some()); - - let held = a.claim("abc123").expect("the signal was kept"); - - assert_eq!(held.kind, SignalKind::StopFailure); - assert_eq!(held.payload["error_type"], "rate_limit"); -} - -#[test] -fn a_claimed_request_is_not_handed_back_a_second_time() { - let mut a = Adoptions::default(); - assert!(a.request(signal("abc123"), Instant::now()).is_some()); - assert!(a.claim("abc123").is_some()); - assert!( - a.claim("abc123").is_none(), - "one signal must not be applied twice" - ); -} - -#[test] -fn a_pane_we_never_asked_about_has_no_signal_waiting_for_it() { - // Every configured pane takes this path: the host named it, so no request was - // ever made for it. - let mut a = Adoptions::default(); - assert!(a.claim("never-asked").is_none()); -} diff --git a/plugins/nightcrow-recovery/src/runloop_io.rs b/plugins/nightcrow-recovery/src/runloop_io.rs index 2436d20d..90833b51 100644 --- a/plugins/nightcrow-recovery/src/runloop_io.rs +++ b/plugins/nightcrow-recovery/src/runloop_io.rs @@ -5,7 +5,6 @@ //! only from the main thread, which is what keeps two half-written lines from //! interleaving on stdout. -use crate::ipc::IpcMessage; use crate::protocol::{LogLevel, PluginCommand, PluginEvent, decode_event, encode_command, log}; use anyhow::Result; use std::io::{BufRead, BufReader, Write}; @@ -17,7 +16,6 @@ pub(crate) enum Message { /// A line from the host that could not be understood. Reported and skipped: /// one bad line is not a reason to abandon a session's panes. HostGarbage(String), - Signal(IpcMessage), /// stdin ended. The host is gone, so there is nothing left to serve. HostGone, } diff --git a/plugins/nightcrow-recovery/src/state.rs b/plugins/nightcrow-recovery/src/state.rs index f445fa48..bf50daac 100644 --- a/plugins/nightcrow-recovery/src/state.rs +++ b/plugins/nightcrow-recovery/src/state.rs @@ -7,12 +7,11 @@ //! cases (a stale generation, a clock jump, an exhausted attempt budget) are //! ordinary unit tests rather than something only reproducible by waiting. //! -//! Safety posture: this machine never decides that a pane is alive or idle; it -//! only repeats back what the host told it, and refuses to ask for input unless -//! the host has said both. The host judges every request again anyway. +//! Safety posture: this machine never decides that a pane is alive; it only +//! repeats back what the host told it. The host judges every request again. use crate::protocol::{PROTOCOL_VERSION, PaneGeneration, PaneToken, PluginCommand, PluginEvent}; -use crate::provider::{LimitEvent, LimitKind}; +use crate::provider::LimitEvent; use crate::wait::ResetWait; use std::time::Instant; @@ -27,9 +26,9 @@ pub const MAX_RESUME_ATTEMPTS: u32 = 4; /// How long a resume has to show some sign of life before it is treated as /// failed. /// -/// A relaunch reports back as a new generation within milliseconds and typed -/// input echoes almost as fast, so this only has to cover a slow provider -/// start-up; a minute and a half is generous and still bounded. +/// A relaunch reports back as a new generation within milliseconds, so this +/// only has to cover a slow provider start-up; a minute and a half is generous +/// and still bounded. pub const RESUME_CONFIRM_SECS: u64 = 90; /// Where a pane is in its recovery. @@ -77,7 +76,6 @@ pub struct PaneRecovery { attempt: u32, /// The host's word on the pane's process, never this machine's guess. alive: bool, - idle: bool, detail: Option, resumed_at: Option, } @@ -92,7 +90,6 @@ impl PaneRecovery { wait: None, attempt: 0, alive: true, - idle: false, detail: None, resumed_at: None, } @@ -142,7 +139,6 @@ impl PaneRecovery { // Either way the machine lands in `Idle`. self.generation = generation; self.alive = true; - self.idle = false; out.extend(if self.state == RecoveryState::Resuming { self.confirm_resume() } else { @@ -152,19 +148,10 @@ impl PaneRecovery { match event { PluginEvent::PaneOpened { .. } => { self.alive = true; - self.idle = false; - } - PluginEvent::PaneOutput { .. } => { - self.idle = false; - out.extend(self.confirm_resume()); - } - PluginEvent::PaneIdle { .. } => { - self.idle = true; - out.extend(self.confirm_resume()); } + PluginEvent::PaneOutput { .. } | PluginEvent::PaneIdle { .. } => {} PluginEvent::PaneExited { .. } => { self.alive = false; - self.idle = false; } PluginEvent::PaneClosed { .. } | PluginEvent::UserInput { .. } => { // The slot is gone, or its human took it back. Either way this @@ -190,13 +177,8 @@ impl PaneRecovery { return Vec::new(); } self.detail = Some(limit.detail.clone()); - let kind = limit.kind; self.limit = Some(limit); let mut out = self.goto(RecoveryState::LimitDetected); - if kind == LimitKind::NeedsHuman { - out.extend(self.goto(RecoveryState::NeedsAttention)); - return out; - } out.extend(self.arm_wait(now_epoch, now)); out } @@ -210,7 +192,6 @@ impl PaneRecovery { self.state != RecoveryState::Idle && current.session_id == limit.session_id && current.resets_at == limit.resets_at - && current.kind == limit.kind } /// A sign that a resume landed. Only meaningful while [`RecoveryState::Resuming`]. diff --git a/plugins/nightcrow-recovery/src/state_clock.rs b/plugins/nightcrow-recovery/src/state_clock.rs index a5461773..0fe252a4 100644 --- a/plugins/nightcrow-recovery/src/state_clock.rs +++ b/plugins/nightcrow-recovery/src/state_clock.rs @@ -6,7 +6,7 @@ use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RESUME_CONFIRM_SECS, RecoveryState}; use crate::protocol::PluginCommand; -use crate::provider::{LimitKind, PaneContext, Provider}; +use crate::provider::{PaneContext, Provider}; use crate::wait::ResetWait; use std::time::{Duration, Instant}; @@ -48,11 +48,7 @@ impl PaneRecovery { pub(super) fn arm_wait(&mut self, now_epoch: i64, now: Instant) -> Vec { // A known reset time is waited out exactly once and does not spend an // attempt: nothing has been tried yet. - let reset = self - .limit - .as_ref() - .filter(|l| l.kind == LimitKind::UsageLimit) - .and_then(|l| l.resets_at); + let reset = self.limit.as_ref().and_then(|l| l.resets_at); if let Some(reset) = reset { self.wait = Some(ResetWait::until(reset, now_epoch, now)); return self.goto(RecoveryState::WaitingForReset); diff --git a/plugins/nightcrow-recovery/src/state_resume.rs b/plugins/nightcrow-recovery/src/state_resume.rs index b87400b6..ea746896 100644 --- a/plugins/nightcrow-recovery/src/state_resume.rs +++ b/plugins/nightcrow-recovery/src/state_resume.rs @@ -9,7 +9,7 @@ //! boundary. That boundary is the host's. use super::{MAX_RESUME_ATTEMPTS, PaneRecovery, RecoveryState}; -use crate::protocol::{MAX_INPUT_BYTES, PROTOCOL_VERSION, PluginCommand}; +use crate::protocol::{PROTOCOL_VERSION, PluginCommand}; use crate::provider::{PaneContext, Provider, ResumePlan}; use std::time::Instant; @@ -50,8 +50,7 @@ impl PaneRecovery { /// /// Staying in [`RecoveryState::ReadyToResume`] with nothing emitted is the /// normal answer while the pane is not yet touchable: a relaunch needs the - /// process gone, typed input needs it alive *and* idle, and both of those - /// facts arrive as later host events. + /// process gone, and that fact arrives as a later host event. pub(super) fn try_resume( &mut self, provider: &dyn Provider, @@ -72,33 +71,10 @@ impl PaneRecovery { self.detail = Some(reason.to_string()); self.goto(RecoveryState::NeedsAttention) } - ResumePlan::Input(data) => self.send_input(data, now_epoch, now), ResumePlan::Relaunch(args) => self.relaunch(args, ctx, now), } } - fn send_input(&mut self, data: String, now_epoch: i64, now: Instant) -> Vec { - if !self.alive { - // The adapter offered typed input for a process that has since - // exited. Do not invent a relaunch on its behalf. - return self.arm_wait_after_failure(now_epoch, now); - } - if !self.idle { - return Vec::new(); - } - if data.is_empty() || data.len() > MAX_INPUT_BYTES { - self.detail = Some("adapter offered input the host would refuse".to_string()); - return self.goto(RecoveryState::NeedsAttention); - } - let command = PluginCommand::SendInput { - v: PROTOCOL_VERSION, - token: self.token.clone(), - generation: self.generation, - data, - }; - self.spend_attempt(command, now) - } - fn relaunch( &mut self, args: Vec, diff --git a/plugins/nightcrow-recovery/src/state_tests/cancel.rs b/plugins/nightcrow-recovery/src/state_tests/cancel.rs index 3909b0c5..1ae10f32 100644 --- a/plugins/nightcrow-recovery/src/state_tests/cancel.rs +++ b/plugins/nightcrow-recovery/src/state_tests/cancel.rs @@ -84,15 +84,6 @@ fn a_cancelled_pane_can_start_a_new_episode() { assert_eq!(states(&out), vec!["limit_detected", "waiting_for_reset"]); } -#[test] -fn a_pane_needing_attention_is_cleared_by_its_human() { - let mut rec = recovery(); - rec.note_limit(needs_human(), T0, Instant::now()); - let out = rec.on_event(&user_input(1)).expect("current generation"); - assert_eq!(rec.state(), RecoveryState::Idle); - assert_eq!(states(&out), vec!["idle"]); -} - #[test] fn two_panes_in_one_repo_recover_their_own_sessions_independently() { let mut first = PaneRecovery::new(TOKEN.to_string(), 1); diff --git a/plugins/nightcrow-recovery/src/state_tests/mod.rs b/plugins/nightcrow-recovery/src/state_tests/mod.rs index c5e15064..705ef05b 100644 --- a/plugins/nightcrow-recovery/src/state_tests/mod.rs +++ b/plugins/nightcrow-recovery/src/state_tests/mod.rs @@ -4,7 +4,7 @@ use super::*; use crate::protocol::PROTOCOL_VERSION; -use crate::provider::{LimitEvent, LimitKind, PaneContext, Provider, ResumePlan}; +use crate::provider::{LimitEvent, PaneContext, Provider, ResumePlan}; use crate::wait::{BACKOFF_BASE_SECS, RESET_GRACE_SECS}; use std::time::Duration; @@ -32,7 +32,7 @@ pub(super) struct FakeProvider { impl Default for FakeProvider { fn default() -> Self { Self { - alive_plan: Some(ResumePlan::Input("continue\r".to_string())), + alive_plan: Some(ResumePlan::Hold("still running")), exited_plan: Some(ResumePlan::Relaunch(vec![ "--resume".to_string(), SESSION.to_string(), @@ -71,7 +71,7 @@ pub(super) fn ctx() -> PaneContext { token: TOKEN.to_string(), generation: 1, cwd: "/w/repo".to_string(), - command: Some("claude".to_string()), + command: Some("codex".to_string()), } } @@ -83,44 +83,17 @@ pub(super) fn usage(session: Option<&str>, resets_at: Option) -> LimitEvent LimitEvent::usage(session.map(str::to_string), resets_at, "test limit") } -pub(super) fn transient() -> LimitEvent { - LimitEvent { - session_id: Some(SESSION.to_string()), - resets_at: Some(RESET), - kind: LimitKind::Transient, - detail: "overloaded".to_string(), - } -} - -pub(super) fn needs_human() -> LimitEvent { - LimitEvent { - session_id: Some(SESSION.to_string()), - resets_at: None, - kind: LimitKind::NeedsHuman, - detail: "billing_error".to_string(), - } -} - pub(super) fn opened(generation: PaneGeneration) -> PluginEvent { PluginEvent::PaneOpened { v: PROTOCOL_VERSION, token: TOKEN.to_string(), generation, title: None, - command: Some("claude".to_string()), + command: Some("codex".to_string()), cwd: "/w/repo".to_string(), } } -pub(super) fn output(generation: PaneGeneration) -> PluginEvent { - PluginEvent::PaneOutput { - v: PROTOCOL_VERSION, - token: TOKEN.to_string(), - generation, - text: "thinking".to_string(), - } -} - pub(super) fn went_idle(generation: PaneGeneration) -> PluginEvent { PluginEvent::PaneIdle { v: PROTOCOL_VERSION, diff --git a/plugins/nightcrow-recovery/src/state_tests/resume.rs b/plugins/nightcrow-recovery/src/state_tests/resume.rs index ead34ce2..dbd0caff 100644 --- a/plugins/nightcrow-recovery/src/state_tests/resume.rs +++ b/plugins/nightcrow-recovery/src/state_tests/resume.rs @@ -49,30 +49,6 @@ fn a_pane_whose_process_is_still_running_is_not_relaunched() { assert!(action(&out).is_none(), "nothing is asked of a live pane"); } -#[test] -fn a_live_pane_is_not_typed_into_until_the_host_says_it_is_idle() { - let mut rec = recovery(); - let provider = FakeProvider::default(); - let mono = Instant::now(); - rec.note_limit(usage(Some(SESSION), Some(RESET)), T0, mono); - let out = tick_at(&mut rec, &provider, T0 + AFTER_RESET, at(mono, AFTER_RESET)); - assert_eq!(rec.state(), RecoveryState::ReadyToResume); - assert!(action(&out).is_none()); - - rec.on_event(&went_idle(1)).expect("current generation"); - let out = tick_at( - &mut rec, - &provider, - T0 + AFTER_RESET + 1, - at(mono, AFTER_RESET + 1), - ); - assert_eq!(rec.state(), RecoveryState::Resuming); - match action(&out) { - Some(PluginCommand::SendInput { data, .. }) => assert_eq!(data, "continue\r"), - other => panic!("expected typed input, got {other:?}"), - } -} - #[test] fn a_relaunch_that_lands_as_a_new_generation_confirms_the_resume() { let mut rec = recovery(); @@ -88,20 +64,6 @@ fn a_relaunch_that_lands_as_a_new_generation_confirms_the_resume() { assert_eq!(states(&out), vec!["idle"]); } -#[test] -fn output_after_typed_input_confirms_the_resume() { - let mut rec = recovery(); - let provider = FakeProvider::default(); - let mono = Instant::now(); - rec.note_limit(usage(Some(SESSION), Some(RESET)), T0, mono); - rec.on_event(&went_idle(1)).expect("current generation"); - tick_at(&mut rec, &provider, T0 + AFTER_RESET, at(mono, AFTER_RESET)); - assert_eq!(rec.state(), RecoveryState::Resuming); - rec.on_event(&output(1)).expect("current generation"); - assert_eq!(rec.state(), RecoveryState::Idle); - assert_eq!(rec.attempt(), 0); -} - #[test] fn a_resume_showing_no_sign_of_life_backs_off_and_tries_again() { let mut rec = recovery(); @@ -185,9 +147,8 @@ fn an_adapter_offering_unsafe_resume_args_is_refused_without_asking_the_host() { assert!(action(&out).is_none()); } -/// A pane the host launched no command in: the plain shell somebody started a -/// provider CLI inside by hand, which is the only kind of pane this plugin can be -/// given on a signal. +/// A pane the host launched no command in. The state machine still rejects a +/// relaunch even though the bundled plugin no longer adopts such panes. fn bare_shell() -> PaneContext { PaneContext { command: None, @@ -216,28 +177,6 @@ fn a_pane_the_host_launched_no_command_in_is_never_relaunched() { assert_eq!(rec.attempt(), 0, "and no attempt is spent learning that"); } -#[test] -fn a_pane_with_no_command_is_still_typed_into_while_its_process_lives() { - // The other half of the same rule: typed input is the whole of the recovery - // such a pane can get, so the missing command line must not cost it that too. - let mut rec = recovery(); - let provider = FakeProvider::default(); - let mono = Instant::now(); - rec.note_limit(usage(Some(SESSION), Some(RESET)), T0, mono); - rec.on_event(&went_idle(1)).expect("current generation"); - let out = rec.tick( - &provider, - &bare_shell(), - T0 + AFTER_RESET, - at(mono, AFTER_RESET), - ); - assert_eq!(rec.state(), RecoveryState::Resuming); - match action(&out) { - Some(PluginCommand::SendInput { data, .. }) => assert_eq!(data, "continue\r"), - other => panic!("expected typed input, got {other:?}"), - } -} - #[test] fn an_adapter_with_nothing_to_say_costs_one_attempt_and_backs_off() { let mut rec = recovery(); diff --git a/plugins/nightcrow-recovery/src/state_tests/transitions.rs b/plugins/nightcrow-recovery/src/state_tests/transitions.rs index ca0cb61e..09650b2c 100644 --- a/plugins/nightcrow-recovery/src/state_tests/transitions.rs +++ b/plugins/nightcrow-recovery/src/state_tests/transitions.rs @@ -40,15 +40,6 @@ fn a_limit_naming_a_different_session_starts_a_new_episode() { assert!(!out.is_empty()); } -#[test] -fn a_transient_failure_backs_off_instead_of_waiting_for_a_usage_window() { - let mut rec = recovery(); - let out = rec.note_limit(transient(), T0, Instant::now()); - assert_eq!(rec.state(), RecoveryState::Backoff); - assert_eq!(rec.deadline_epoch(), Some(T0 + BACKOFF_BASE_SECS)); - assert_eq!(states(&out), vec!["limit_detected", "backoff"]); -} - #[test] fn a_limit_with_no_known_reset_backs_off() { let mut rec = recovery(); @@ -57,25 +48,6 @@ fn a_limit_with_no_known_reset_backs_off() { assert_eq!(rec.deadline_epoch(), Some(T0 + BACKOFF_BASE_SECS)); } -#[test] -fn a_failure_only_a_human_can_fix_goes_straight_to_needs_attention() { - let mut rec = recovery(); - let out = rec.note_limit(needs_human(), T0, Instant::now()); - assert_eq!(rec.state(), RecoveryState::NeedsAttention); - assert_eq!(states(&out), vec!["limit_detected", "needs_attention"]); - assert!(action(&out).is_none()); -} - -#[test] -fn needs_attention_ignores_every_later_limit_report() { - let mut rec = recovery(); - let mono = Instant::now(); - rec.note_limit(needs_human(), T0, mono); - let out = rec.note_limit(usage(Some(SESSION), Some(RESET)), T0, mono); - assert!(out.is_empty()); - assert_eq!(rec.state(), RecoveryState::NeedsAttention); -} - #[test] fn a_stale_generation_cannot_drive_a_transition() { let mut rec = recovery(); diff --git a/plugins/nightcrow-recovery/src/transport.rs b/plugins/nightcrow-recovery/src/transport.rs deleted file mode 100644 index d513a66b..00000000 --- a/plugins/nightcrow-recovery/src/transport.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! 플랫폼별 Unix 소켓 타입의 단일 진입점. -#[cfg(unix)] -pub(crate) use std::os::unix::net::{UnixListener, UnixStream}; -#[cfg(windows)] -pub(crate) use uds_windows::{UnixListener, UnixStream}; diff --git a/plugins/nightcrow-recovery/src/wait.rs b/plugins/nightcrow-recovery/src/wait.rs index 035c2c59..203db518 100644 --- a/plugins/nightcrow-recovery/src/wait.rs +++ b/plugins/nightcrow-recovery/src/wait.rs @@ -28,9 +28,8 @@ pub const JUMP_TOLERANCE_SECS: i64 = 5; /// actually cleared, so every wait has a floor. pub const MIN_WAIT_SECS: i64 = 15; -/// Longest wait. Claude's longest documented window is seven days; eight days -/// is past anything legitimate, so a deadline that would exceed it is clamped -/// rather than parking a pane indefinitely. Must stay under the host's +/// Longest wait. A deadline beyond eight days is clamped rather than parking a +/// pane indefinitely. Must stay under the host's /// `PENDING_RELAUNCH_TTL` (nine days): a wait outlasting that would end with /// nothing left to resume. pub const MAX_WAIT_SECS: i64 = 8 * 24 * 60 * 60; diff --git a/src/AGENTS.md b/src/AGENTS.md index fe0b1a5a..cb7ef2cd 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,6 +1,6 @@ # `src/` scope -이 가이드는 `src/`의 Rust core에만 적용된다. 저장소 전체 규칙과 설계 기준은 [루트 가이드](../AGENTS.md)를 먼저 읽고, 공통 플랫폼·코드 품질 규칙은 [guardrails](../.agents/rules/guardrails.md), 테스트 배치는 [testing rules](../.agents/rules/testing.md), 전체 불변식은 [architecture index](../docs/architecture.md)를 따른다. 이 문서에는 그 규칙을 반복하지 않고 `src/`의 비자명한 경계만 적는다. +이 가이드는 `src/`의 Rust core에만 적용된다. 저장소 공통 규칙은 [루트 가이드](../AGENTS.md), 설계 불변식은 [architecture index](../docs/architecture.md)를 따른다. 아래에는 `src/`의 비자명한 경계만 적는다. ## Core boundaries diff --git a/src/app/log_nav.rs b/src/app/log_nav.rs index 2969c1f0..b4af1131 100644 --- a/src/app/log_nav.rs +++ b/src/app/log_nav.rs @@ -1,5 +1,17 @@ use super::{App, LIST_PAGE_SIZE, ViewMode}; +fn resolve_filtered_selection(indices: &[usize], selected: usize, delta: isize) -> Option { + if indices.is_empty() { + return None; + } + let position = match indices.iter().position(|&index| index == selected) { + Some(position) => position, + None => return Some(indices[0]), + }; + let last = indices.len() as isize - 1; + Some(indices[(position as isize).saturating_add(delta).clamp(0, last) as usize]) +} + impl App { pub fn log_commit_filtered_indices(&self) -> &[usize] { &self.git.view.log.commits_filter_cache @@ -12,16 +24,13 @@ impl App { // Returns whether selection changed so the caller can decide whether to // reload the diff. fn sync_log_commit_selection_to_filter(&mut self) -> bool { - let target = { - let indices = self.log_commit_filtered_indices(); - if indices.is_empty() { - return false; - } - if indices.contains(&self.git.view.log.selected) { - self.git.view.log.selected - } else { - indices[0] - } + let target = match resolve_filtered_selection( + self.log_commit_filtered_indices(), + self.git.view.log.selected, + 0, + ) { + Some(target) => target, + None => return false, }; if target == self.git.view.log.selected { false @@ -33,16 +42,13 @@ impl App { } fn sync_log_file_selection_to_filter(&mut self) -> bool { - let target = { - let indices = self.log_file_filtered_indices(); - if indices.is_empty() { - return false; - } - if indices.contains(&self.git.view.log.file_selected) { - self.git.view.log.file_selected - } else { - indices[0] - } + let target = match resolve_filtered_selection( + self.log_file_filtered_indices(), + self.git.view.log.file_selected, + 0, + ) { + Some(target) => target, + None => return false, }; if target == self.git.view.log.file_selected { false @@ -218,22 +224,13 @@ impl App { // Returns whether the selection actually moved so callers can decide to // reload the diff. pub(crate) fn move_log_commit_in_filter(&mut self, delta: isize) -> bool { - let resolved = { - let indices = self.log_commit_filtered_indices(); - if indices.is_empty() { - return false; - } - let pos = indices - .iter() - .position(|&i| i == self.git.view.log.selected); - let new_pos = match pos { - Some(p) => { - let last = indices.len() as isize - 1; - (p as isize + delta).clamp(0, last) as usize - } - None => 0, - }; - indices[new_pos] + let resolved = match resolve_filtered_selection( + self.log_commit_filtered_indices(), + self.git.view.log.selected, + delta, + ) { + Some(resolved) => resolved, + None => return false, }; if resolved == self.git.view.log.selected { false @@ -244,22 +241,13 @@ impl App { } pub(crate) fn move_log_file_in_filter(&mut self, delta: isize) -> bool { - let resolved = { - let indices = self.log_file_filtered_indices(); - if indices.is_empty() { - return false; - } - let pos = indices - .iter() - .position(|&i| i == self.git.view.log.file_selected); - let new_pos = match pos { - Some(p) => { - let last = indices.len() as isize - 1; - (p as isize + delta).clamp(0, last) as usize - } - None => 0, - }; - indices[new_pos] + let resolved = match resolve_filtered_selection( + self.log_file_filtered_indices(), + self.git.view.log.file_selected, + delta, + ) { + Some(resolved) => resolved, + None => return false, }; if resolved == self.git.view.log.file_selected { false diff --git a/src/app/tests/log_search.rs b/src/app/tests/log_search.rs index 5fee6ccd..6c327a29 100644 --- a/src/app/tests/log_search.rs +++ b/src/app/tests/log_search.rs @@ -128,3 +128,45 @@ fn drilldown_file_search_filters_paths_and_clamps_selection() { assert_eq!(app.log_file_filtered_indices(), &[0, 1, 2]); assert!(app.git.view.log.file_search_query.is_empty()); } + +#[test] +fn log_navigation_clamps_missing_and_empty_filter_selection() { + let mut app = app_with_files(vec![]); + app.git.view.mode = ViewMode::Log; + app.git.view.log.set_commits(vec![ + named_commit("first"), + named_commit("second"), + named_commit("third"), + ]); + app.git.view.log.selected = usize::MAX; + + assert!(app.move_log_commit_in_filter(1)); + assert_eq!(app.git.view.log.selected, 0); + assert!(!app.move_log_commit_in_filter(-1)); + assert!(!app.move_log_commit_in_filter(-isize::MAX)); + assert!(app.move_log_commit_in_filter(isize::MAX)); + assert_eq!(app.git.view.log.selected, 2); + assert!(!app.move_log_commit_in_filter(1)); + app.git.view.log.selected = 1; + assert!(app.move_log_commit_in_filter(isize::MAX)); + assert_eq!(app.git.view.log.selected, 2); + app.git.view.log.selected = 1; + assert!(app.move_log_commit_in_filter(isize::MIN)); + assert_eq!(app.git.view.log.selected, 0); + + app.git.view.log.drill_down = true; + app.git.view.log.set_commit_files(vec![ + ChangedFile::unstaged_only("first.rs".into(), StatusKind::Modified), + ChangedFile::unstaged_only("second.rs".into(), StatusKind::Modified), + ]); + app.git.view.log.file_selected = usize::MAX; + + assert!(app.move_log_file_in_filter(1)); + assert_eq!(app.git.view.log.file_selected, 0); + assert!(!app.move_log_file_in_filter(-1)); + assert!(app.move_log_file_in_filter(isize::MAX)); + assert_eq!(app.git.view.log.file_selected, 1); + + app.git.view.log.set_commit_files(vec![]); + assert!(!app.move_log_file_in_filter(1)); +} diff --git a/src/application/session_link.rs b/src/application/session_link.rs index f94499ca..6e094701 100644 --- a/src/application/session_link.rs +++ b/src/application/session_link.rs @@ -57,6 +57,9 @@ impl SessionLink { // Answered during the handshake; a later one would mean the // daemon restarted under this client. ServerMessage::Hello { .. } => {} + // A status connection closes before attachment, so this is not + // a response the long-lived session client can request. + ServerMessage::Status { .. } => {} // Only a refusal reaches here. A pane created, exited, or // reordered goes to that repository's backend. ServerMessage::Terminal { repo, event } => { diff --git a/src/application/session_terminals_tests.rs b/src/application/session_terminals_tests.rs index d518f7ef..bf2e8e98 100644 --- a/src/application/session_terminals_tests.rs +++ b/src/application/session_terminals_tests.rs @@ -29,7 +29,8 @@ fn attached(dir: &tempfile::TempDir, repos: &[String]) -> (DaemonSocket, DaemonC let listener = socket.listener().try_clone().expect("clones"); let state = crate::test_util::session_state(repos, dir.path()); let (shutdown_tx, _shutdown_rx) = std::sync::mpsc::sync_channel(1); - let session = crate::daemon::serve::start(state, shutdown_tx).expect("starts the watcher"); + let session = + crate::daemon::serve::start(state, socket.path(), shutdown_tx).expect("starts the watcher"); std::thread::spawn(move || crate::daemon::serve::serve(listener, session)); let client = DaemonClient::connect(socket.path()).expect("attaches"); (socket, client) diff --git a/src/cli.rs b/src/cli.rs index 6f7be322..6f547daa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,12 +5,14 @@ mod attach; mod daemon; mod init; pub(crate) mod plugin_cmd; +mod status; mod stop; mod update; pub(crate) use attach::run_attach_detached; pub(crate) use daemon::run_daemon; pub(crate) use init::run_init; +pub(crate) use status::run_status; pub(crate) use stop::run_stop; pub(crate) use update::run_update; @@ -84,6 +86,12 @@ pub(crate) enum Commands { #[arg(long)] socket: Option, }, + /// Show the state of a running daemon without attaching a client. + Status { + /// Path to the daemon socket. Defaults to the standard location. + #[arg(long)] + socket: Option, + }, /// Reinstall nightcrow, replacing the binary this command is running from. /// /// Runs `cargo install --locked --force`, so it needs a Rust toolchain. diff --git a/src/cli/daemon.rs b/src/cli/daemon.rs index 856674e5..cc70091d 100644 --- a/src/cli/daemon.rs +++ b/src/cli/daemon.rs @@ -113,7 +113,7 @@ pub(crate) fn run_daemon( .listener() .try_clone() .context("cloning the daemon listener")?; - let session = crate::daemon::serve::start(server.session_state(), shutdown_tx)?; + let session = crate::daemon::serve::start(server.session_state(), socket.path(), shutdown_tx)?; std::thread::Builder::new() .name("nightcrow-daemon-accept".into()) .spawn(move || crate::daemon::serve::serve(listener, session)) diff --git a/src/cli/status.rs b/src/cli/status.rs new file mode 100644 index 00000000..06714da6 --- /dev/null +++ b/src/cli/status.rs @@ -0,0 +1,129 @@ +use anyhow::{Result, bail}; +use std::path::PathBuf; +use std::time::Duration; + +use crate::daemon::one_shot::request; +use crate::daemon::protocol::{ + ClientMessage, DaemonStatus, RepositoryStatus, ServerMessage, version, +}; + +#[path = "status_render.rs"] +mod status_render; + +use status_render::render_status; + +const STATUS_TIMEOUT: Duration = Duration::from_secs(5); + +/// Query the daemon without creating an attach client or terminal subscription. +pub(crate) fn run_status(socket: Option) -> Result<()> { + let path = resolve_socket_path(socket, crate::daemon::socket::default_socket_path)?; + let status = query_status(&path)?; + println!("{}", render_status(&status)); + Ok(()) +} + +fn resolve_socket_path(socket: Option, default_path: F) -> Result +where + F: FnOnce() -> Result, +{ + match socket { + Some(path) => Ok(path), + None => default_path(), + } +} + +fn query_status(path: &std::path::Path) -> Result { + let response = match request(path, &ClientMessage::Status {}, STATUS_TIMEOUT) { + Ok(response) => response, + Err(error) if socket_unavailable(&error) => { + bail!( + "daemon unavailable at {}: no socket or listener is running; start a session with `nightcrow -d`", + path.display() + ) + } + Err(error) => return Err(error), + }; + let status = decode_status(response)?; + validate_status(&status)?; + Ok(status) +} + +fn socket_unavailable(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) + }) +} + +fn decode_status(response: ServerMessage) -> Result { + match response { + ServerMessage::Status { status } => { + let expected = version(); + if status.version != expected { + bail!( + "version mismatch: daemon reports {}, this client expects {}", + status_render::display_text(&status.version), + expected + ); + } + Ok(status) + } + ServerMessage::Error { message } => { + bail!("protocol error: daemon rejected the status request: {message}") + } + other => bail!("protocol error: unexpected response to status request: {other:?}"), + } +} + +fn validate_status(status: &DaemonStatus) -> Result<()> { + if status.pid == 0 { + bail!("protocol error: malformed status response: PID is zero"); + } + if let Ok(endpoint) = &status.endpoint + && endpoint.is_empty() + { + bail!("protocol error: malformed status response: endpoint is empty"); + } + let mut client_ids = status.attached_clients.clone(); + client_ids.sort_unstable(); + if client_ids.windows(2).any(|ids| ids[0] == ids[1]) { + bail!("protocol error: malformed status response: duplicate client id"); + } + let mut repo_ids = Vec::with_capacity(status.repositories.len()); + for repo in &status.repositories { + validate_repository(repo)?; + repo_ids.push(&repo.id); + } + repo_ids.sort_unstable(); + if repo_ids.windows(2).any(|ids| ids[0] == ids[1]) { + bail!("protocol error: malformed status response: duplicate repository id"); + } + Ok(()) +} + +fn validate_repository(repo: &RepositoryStatus) -> Result<()> { + if repo.id.is_empty() || repo.path.is_empty() { + bail!("protocol error: malformed status response: repository identity is empty"); + } + if repo.pane_count != repo.panes.len() { + bail!( + "protocol error: malformed status response: repository {} pane count disagrees with pane ids", + status_render::display_text(&repo.id) + ); + } + let mut panes = repo.panes.clone(); + panes.sort_unstable(); + if panes.windows(2).any(|ids| ids[0] == ids[1]) { + bail!( + "protocol error: malformed status response: repository {} has duplicate pane id", + status_render::display_text(&repo.id) + ); + } + Ok(()) +} + +#[cfg(test)] +#[path = "status_tests.rs"] +mod tests; diff --git a/src/cli/status_render.rs b/src/cli/status_render.rs new file mode 100644 index 00000000..1e0ef5dc --- /dev/null +++ b/src/cli/status_render.rs @@ -0,0 +1,166 @@ +use std::fmt::Write as _; + +use crate::daemon::protocol::{DaemonStatus, StatusUnavailable, StatusUnavailableReason}; + +pub(super) fn render_status(status: &DaemonStatus) -> String { + let mut output = String::new(); + writeln!(output, "Status: running").unwrap(); + writeln!(output, "PID: {}", status.pid).unwrap(); + writeln!(output, "Version: {}", status.version).unwrap(); + writeln!( + output, + "Started at: {}", + format_started_at(&status.started_at_unix_ms) + ) + .unwrap(); + writeln!(output, "Uptime: {}", format_uptime(status.uptime_ms)).unwrap(); + writeln!(output, "Endpoint: {}", format_endpoint(&status.endpoint)).unwrap(); + + let mut clients = status.attached_clients.clone(); + clients.sort_unstable(); + writeln!(output, "Attached clients: {}", clients.len()).unwrap(); + writeln!(output, "Attached client IDs: {}", format_list(&clients)).unwrap(); + + let mut repositories = status.repositories.clone(); + repositories.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.path.cmp(&right.path)) + }); + writeln!(output, "Repositories: {}", repositories.len()).unwrap(); + if repositories.is_empty() { + writeln!(output, " (none)").unwrap(); + } else { + for repository in repositories { + writeln!(output, " Repository: {}", display_text(&repository.id)).unwrap(); + writeln!(output, " Path: {}", display_text(&repository.path)).unwrap(); + writeln!(output, " Pane count: {}", repository.pane_count).unwrap(); + let mut panes = repository.panes; + panes.sort_unstable(); + writeln!(output, " Pane IDs: {}", format_list(&panes)).unwrap(); + } + } + output.pop(); + output +} + +fn format_list(values: &[T]) -> String { + if values.is_empty() { + "(none)".to_string() + } else { + values + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + } +} + +fn format_started_at(value: &Result) -> String { + match value { + Ok(milliseconds) => format_utc_millis(*milliseconds), + Err(unavailable) => unavailable_text(&unavailable.reason), + } +} + +fn format_endpoint(value: &Result) -> String { + match value { + Ok(endpoint) => display_text(endpoint), + Err(unavailable) => unavailable_text(&unavailable.reason), + } +} + +fn unavailable_text(reason: &StatusUnavailableReason) -> String { + let reason = match reason { + StatusUnavailableReason::ClockBeforeUnixEpoch => "clock before Unix epoch", + StatusUnavailableReason::EndpointNotUnicode => "endpoint path is not valid Unicode", + }; + format!("unavailable ({reason})") +} + +/// Keep daemon-controlled paths and ids on one terminal line without emitting +/// C0/C1 controls, including escape sequences and OSC payloads. +pub(super) fn display_text(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + character if character.is_control() => { + write!(escaped, "\\u{{{:04x}}}", character as u32).unwrap(); + } + character => escaped.push(character), + } + } + escaped +} + +fn format_utc_millis(milliseconds: u64) -> String { + const MILLIS_PER_DAY: u64 = 86_400_000; + let days = milliseconds / MILLIS_PER_DAY; + let Some(days) = i64::try_from(days) + .ok() + .filter(|days| *days <= i64::MAX - 719_468) + else { + return format!("unix-ms:{milliseconds}"); + }; + let day_millis = milliseconds % MILLIS_PER_DAY; + let hour = day_millis / 3_600_000; + let minute = (day_millis / 60_000) % 60; + let second = (day_millis / 1_000) % 60; + let millis = day_millis % 1_000; + let (year, month, day) = civil_date(days); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +fn civil_date(days_since_unix_epoch: i64) -> (i64, i64, i64) { + let shifted = days_since_unix_epoch + 719_468; + let era = (if shifted >= 0 { + shifted + } else { + shifted - 146_096 + }) / 146_097; + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_part = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_part + 2) / 5 + 1; + let month = month_part + if month_part < 10 { 3 } else { -9 }; + (year + if month <= 2 { 1 } else { 0 }, month, day) +} + +fn format_uptime(milliseconds: u64) -> String { + const MILLIS_PER_SECOND: u64 = 1_000; + const SECONDS_PER_MINUTE: u64 = 60; + const MINUTES_PER_HOUR: u64 = 60; + const HOURS_PER_DAY: u64 = 24; + + let mut seconds = milliseconds / MILLIS_PER_SECOND; + let days = seconds / (HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE); + seconds %= HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE; + let hours = seconds / (MINUTES_PER_HOUR * SECONDS_PER_MINUTE); + seconds %= MINUTES_PER_HOUR * SECONDS_PER_MINUTE; + let minutes = seconds / SECONDS_PER_MINUTE; + let seconds = seconds % SECONDS_PER_MINUTE; + let mut units = Vec::new(); + if days > 0 { + units.push(format!("{days}d")); + } + if hours > 0 { + units.push(format!("{hours}h")); + } + if minutes > 0 { + units.push(format!("{minutes}m")); + } + if seconds > 0 || units.is_empty() { + units.push(format!("{seconds}s")); + } + units.join(" ") +} + +#[cfg(test)] +#[path = "status_render_tests.rs"] +mod tests; diff --git a/src/cli/status_render_tests.rs b/src/cli/status_render_tests.rs new file mode 100644 index 00000000..d4edd03c --- /dev/null +++ b/src/cli/status_render_tests.rs @@ -0,0 +1,111 @@ +use super::*; + +use crate::daemon::protocol::{ + DaemonStatus, RepositoryStatus, StatusUnavailable, StatusUnavailableReason, version, +}; + +fn status() -> DaemonStatus { + DaemonStatus { + pid: 42, + version: version(), + started_at_unix_ms: Ok(1_735_689_723_004), + uptime_ms: 90_061_000, + endpoint: Ok("custom.sock".into()), + attached_clients: vec![9, 2], + repositories: vec![ + RepositoryStatus { + id: "b".into(), + path: "/b".into(), + pane_count: 0, + panes: vec![], + }, + RepositoryStatus { + id: "a".into(), + path: "/a".into(), + pane_count: 2, + panes: vec![8, 3], + }, + ], + } +} + +#[test] +fn status_output_sorts_ids_and_repositories_and_names_empty_values() { + let output = render_status(&status()); + assert!(output.contains("Status: running")); + assert!(output.contains("Started at: 2025-01-01T00:02:03.004Z")); + assert!(output.contains("Uptime: 1d 1h 1m 1s")); + assert!(output.contains("Attached client IDs: 2, 9")); + assert!(output.find("Repository: a") < output.find("Repository: b")); + assert!(output.contains(" Pane IDs: 3, 8")); + assert!(output.contains(" Pane IDs: (none)")); +} + +#[test] +fn status_output_explains_unavailable_start_time() { + let mut status = status(); + status.started_at_unix_ms = Err(StatusUnavailable { + reason: StatusUnavailableReason::ClockBeforeUnixEpoch, + }); + let output = render_status(&status); + assert!(output.contains("Started at: unavailable (clock before Unix epoch)")); +} + +#[test] +fn status_output_explains_empty_repository_set() { + let mut status = status(); + status.repositories.clear(); + let output = render_status(&status); + assert!(output.contains("Repositories: 0\n (none)")); +} + +#[test] +fn status_output_explains_unavailable_endpoint() { + let mut status = status(); + status.endpoint = Err(StatusUnavailable { + reason: StatusUnavailableReason::EndpointNotUnicode, + }); + assert!( + render_status(&status) + .contains("Endpoint: unavailable (endpoint path is not valid Unicode)") + ); +} + +#[test] +fn status_output_escapes_control_characters_and_preserves_unicode() { + let mut status = status(); + status.endpoint = Ok("sock\u{1b}]0;evil\u{7}\n\u{9b}".into()); + status.repositories[0].id = "repo-한글\u{1b}".into(); + status.repositories[0].path = "C:\\work\n\u{80}".into(); + let output = render_status(&status); + assert!(output.contains("repo-한글")); + assert!(output.contains("\\u{001b}")); + assert!(output.contains("\\u{009b}")); + assert!(output.contains("\\n")); + assert!(output.contains("\\u{0007}")); + assert!( + output + .lines() + .all(|line| { line.chars().all(|character| !character.is_control()) }) + ); +} + +#[test] +fn an_unrepresentable_start_time_is_rendered_without_panicking() { + let mut status = status(); + status.started_at_unix_ms = Ok(u64::MAX); + assert!(render_status(&status).contains("Started at: ")); +} + +#[test] +fn utc_start_time_format_handles_epoch_and_leap_year_boundaries() { + assert_eq!(format_utc_millis(0), "1970-01-01T00:00:00.000Z"); + assert_eq!( + format_utc_millis(951_782_400_000), + "2000-02-29T00:00:00.000Z" + ); + assert_eq!( + format_utc_millis(4_107_542_400_000), + "2100-03-01T00:00:00.000Z" + ); +} diff --git a/src/cli/status_tests.rs b/src/cli/status_tests.rs new file mode 100644 index 00000000..24e00d49 --- /dev/null +++ b/src/cli/status_tests.rs @@ -0,0 +1,91 @@ +use super::*; +use clap::Parser; + +use crate::cli::{Cli, Commands}; +use crate::daemon::protocol::{DaemonStatus, RepositoryStatus, ServerMessage, version}; + +#[test] +fn status_subcommand_accepts_an_optional_socket_override() { + let cli = Cli::try_parse_from(["nightcrow", "status", "--socket", "custom.sock"]).unwrap(); + match cli.command { + Some(Commands::Status { socket }) => { + assert_eq!(socket.unwrap(), std::path::PathBuf::from("custom.sock")); + } + _ => panic!("expected status command"), + } +} + +#[test] +fn status_subcommand_defaults_to_the_standard_socket() { + let cli = Cli::try_parse_from(["nightcrow", "status"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Status { socket: None }) + )); +} + +#[test] +fn explicit_socket_override_does_not_evaluate_the_default_socket() { + let expected = std::path::PathBuf::from("custom.sock"); + let actual = resolve_socket_path(Some(expected.clone()), || { + anyhow::bail!("default socket path should not be evaluated") + }) + .unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn a_missing_daemon_is_distinguished_from_a_protocol_failure() { + let dir = tempfile::TempDir::new().unwrap(); + let error = query_status(&dir.path().join("missing.sock")).unwrap_err(); + assert!( + error.to_string().contains("daemon unavailable"), + "{error:#}" + ); + assert!(error.to_string().contains("nightcrow -d"), "{error:#}"); +} + +#[test] +fn a_version_mismatch_is_reported_as_a_version_error() { + let status = DaemonStatus { + pid: 1, + version: "old".into(), + started_at_unix_ms: Ok(0), + uptime_ms: 0, + endpoint: Ok("sock".into()), + repositories: vec![], + attached_clients: vec![], + }; + let error = decode_status(ServerMessage::Status { status }).unwrap_err(); + assert!(error.to_string().contains("version mismatch"), "{error:#}"); +} + +#[test] +fn an_unexpected_server_message_is_reported_as_a_protocol_error() { + let error = decode_status(ServerMessage::Hello { + version: version(), + client: 1, + }) + .unwrap_err(); + assert!(error.to_string().contains("protocol error"), "{error:#}"); +} + +#[test] +fn malformed_status_facts_are_rejected_before_rendering() { + let status = DaemonStatus { + pid: 1, + version: version(), + started_at_unix_ms: Ok(0), + uptime_ms: 0, + endpoint: Ok("sock".into()), + repositories: vec![RepositoryStatus { + id: "repo".into(), + path: "/repo".into(), + pane_count: 2, + panes: vec![1], + }], + attached_clients: vec![], + }; + let error = validate_status(&status).unwrap_err(); + assert!(error.to_string().contains("malformed status"), "{error:#}"); +} diff --git a/src/cli/stop.rs b/src/cli/stop.rs index b2c2aaac..71b1ce6c 100644 --- a/src/cli/stop.rs +++ b/src/cli/stop.rs @@ -1,11 +1,11 @@ use anyhow::{Context, Result}; -use std::io::{Read, Write}; +use std::io::Read; use std::path::PathBuf; use std::time::{Duration, Instant}; -use crate::daemon::frame::{Frame, FrameKind, read_frame, write_frame}; -use crate::daemon::protocol::{ClientMessage, ServerMessage}; -use crate::daemon::transport::UnixStream; +use crate::daemon::frame::{FrameKind, read_frame}; +use crate::daemon::one_shot::{connect, send_request}; +use crate::daemon::protocol::ServerMessage; // The daemon's cleanup normally takes milliseconds, but a configured plugin may // take up to 200 ms per host before it is force-killed. Keep room for the @@ -26,16 +26,17 @@ pub(crate) fn run_stop(socket: Option) -> Result<()> { ); } - let mut stream = UnixStream::connect(&path).with_context(|| { + let mut stream = connect(&path).with_context(|| { format!( "could not connect to the daemon socket at {} — the daemon may have stopped", path.display() ) })?; - let json = - serde_json::to_vec(&ClientMessage::Shutdown).context("encoding the shutdown request")?; - write_frame(&mut stream, &Frame::control(json)).context("sending the shutdown request")?; - stream.flush().context("flushing the shutdown request")?; + send_request( + &mut stream, + &crate::daemon::protocol::ClientMessage::Shutdown, + ) + .context("sending the shutdown request")?; stream .set_read_timeout(Some(SHUTDOWN_ACK_TIMEOUT)) .context("setting the shutdown acknowledgment timeout")?; @@ -46,11 +47,12 @@ pub(crate) fn run_stop(socket: Option) -> Result<()> { Ok(()) } -/// Consume unsolicited frames until the daemon closes this connection. +/// Consume any frames until the daemon closes this one-shot connection. /// -/// An attach socket speaks first, so a `Repos` or terminal frame may be ahead of -/// the shutdown request's outcome. Only EOF, or a reset/abort while the daemon -/// is closing, proves that shutdown has reached the daemon's exit path. +/// A current daemon closes immediately after accepting the one-shot request; +/// older daemons may have queued session frames first. Only EOF, or a +/// reset/abort while the daemon is closing, proves shutdown reached its exit +/// path. fn wait_for_shutdown_ack(reader: &mut R, deadline: Instant) -> Result<()> { loop { if Instant::now() >= deadline { diff --git a/src/cli/stop_tests.rs b/src/cli/stop_tests.rs index 88cc4a5f..557def6f 100644 --- a/src/cli/stop_tests.rs +++ b/src/cli/stop_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::daemon::frame::write_frame; +use crate::daemon::frame::{Frame, write_frame}; use std::io::{self, Cursor, Read}; use std::time::{Duration, Instant}; diff --git a/src/daemon/client.rs b/src/daemon/client.rs index cc24bc25..5e45624f 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -1,8 +1,8 @@ -//! The attaching side of the daemon socket. The daemon speaks first — the -//! session changes under the client, and terminal output arrives unprompted — -//! so requests are sent and forgotten, and everything the daemon says lands in -//! a queue the caller drains on its own schedule (a TUI frame, which must never -//! block on a socket). +//! The attaching side of the daemon socket. The client sends `Hello` as its +//! first frame; after that the daemon speaks unprompted because the session can +//! change under the client. Everything the daemon says lands in a queue the +//! caller drains on its own schedule (a TUI frame, which must never block on a +//! socket). use super::protocol::{ClientMessage, ServerMessage, version}; use super::terminal_link::{TerminalLink, TerminalRouter}; @@ -13,13 +13,9 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Receiver; use std::sync::{Arc, Mutex}; -use std::time::Duration; - /// How long the opening handshake waits for the daemon to answer. Only the /// handshake is bounded — after it the connection is event-driven and a quiet /// daemon is the normal state. -const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); - /// Why opening a daemon client failed, so an attach caller can retry only /// when there was no listener to connect to. A listener that rejects or fails /// the handshake is still a daemon failure, not an invitation to start a @@ -58,7 +54,7 @@ impl DaemonClient { /// Attach to the daemon listening on `path`. Completes the version handshake /// before returning, so a caller that gets a `DaemonClient` knows it is /// talking to a daemon of this build. The repository set the daemon - /// volunteers on attach is queued like any other message. + /// sends after accepting the hello is queued like any other message. #[cfg(test)] pub fn connect(path: &Path) -> Result { Self::connect_for_attach(path).map_err(ConnectError::into_error) @@ -72,7 +68,7 @@ impl DaemonClient { let unavailable = is_unavailable_socket_error(&err); let context = if unavailable { format!( - "no nightcrow daemon on {} — start one with `nightcrow serve`", + "no nightcrow daemon on {} — start one with `nightcrow -d`", path.display() ) } else { @@ -99,7 +95,7 @@ impl DaemonClient { // Bounded only for the handshake; cleared before the reader thread takes // over, or an idle session would read as a dead one. reader - .set_read_timeout(Some(HANDSHAKE_TIMEOUT)) + .set_read_timeout(Some(super::HANDSHAKE_TIMEOUT)) .context("setting the handshake timeout")?; let mut queued = Vec::new(); let client = loop { @@ -121,9 +117,8 @@ impl DaemonClient { } break client; } - // The daemon volunteers the repository set on attach, which can - // arrive before the handshake answer. Kept: it is the state - // this client is about to render. + // Session traffic is queued if it races the hello answer. Kept: + // it is the state this client is about to render. other @ (ServerMessage::Repos { .. } | ServerMessage::Terminal { .. }) => { queued.push(other) } @@ -132,6 +127,9 @@ impl DaemonClient { ServerMessage::Reloaded { .. } => { tracing::debug!("attach: a reload answer arrived before the handshake"); } + ServerMessage::Status { .. } => { + bail!("daemon answered an attach handshake with a status response") + } } }; // Best-effort: macOS rejects the option on a socket whose peer has diff --git a/src/daemon/client_tests.rs b/src/daemon/client_tests.rs index 612a4f4b..8639d298 100644 --- a/src/daemon/client_tests.rs +++ b/src/daemon/client_tests.rs @@ -34,7 +34,8 @@ fn daemon(dir: &tempfile::TempDir, repos: &[String]) -> TestDaemon { }, )); let (shutdown_tx, _shutdown_rx) = std::sync::mpsc::sync_channel(1); - let session = crate::daemon::serve::start(state, shutdown_tx).expect("starts the watcher"); + let session = + crate::daemon::serve::start(state, socket.path(), shutdown_tx).expect("starts the watcher"); std::thread::spawn(move || crate::daemon::serve::serve(listener, session)); TestDaemon { socket } } diff --git a/src/daemon/clients.rs b/src/daemon/clients.rs index dd8e336c..ce1a6016 100644 --- a/src/daemon/clients.rs +++ b/src/daemon/clients.rs @@ -120,6 +120,16 @@ impl AttachedClients { self.inner.lock().expect("attached clients poisoned").len() } + /// Attached protocol client ids, never terminal-hub connection ids. + pub fn ids(&self) -> Vec { + self.inner + .lock() + .expect("attached clients poisoned") + .iter() + .map(|client| client.id) + .collect() + } + /// Send `frame` to every attached client, and count them told: nobody is /// left owed a set by a broadcast that just reached them. /// diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 55bb4b06..417477d8 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -7,15 +7,23 @@ //! the user can open, so it does not. Keeping the two transports apart is what //! keeps that difference from becoming a mistake in a shared code path. +use std::time::Duration; + +/// Maximum time the daemon spends waiting for a pre-attach handshake frame. +/// Stateful attach clears this timeout as soon as `Hello` is accepted. +pub(crate) const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + pub(crate) mod client; pub(crate) mod clients; pub(crate) mod detach; pub(crate) mod frame; pub(crate) mod lock; +pub(crate) mod one_shot; pub(crate) mod protocol; pub(crate) mod requests; pub(crate) mod serve; pub(crate) mod socket; +mod status; pub(crate) mod terminal_link; pub(crate) mod terminals; pub(crate) mod transport; diff --git a/src/daemon/one_shot.rs b/src/daemon/one_shot.rs new file mode 100644 index 00000000..552788e0 --- /dev/null +++ b/src/daemon/one_shot.rs @@ -0,0 +1,52 @@ +//! The short request path used before a daemon connection becomes stateful. +//! +//! Status is deliberately not a [`DaemonClient`]: it writes one control frame, +//! reads one control response, and lets the daemon close the connection. Stop +//! shares only this small connect/write seam; its EOF acknowledgment remains +//! specialized in the CLI. + +use super::frame::{Frame, FrameKind, read_frame, write_frame}; +use super::protocol::{ClientMessage, ServerMessage}; +use super::transport::UnixStream; +use anyhow::{Context, Result, bail}; +use std::io::Write; +use std::path::Path; +use std::time::Duration; + +pub(crate) fn connect(path: &Path) -> std::io::Result { + UnixStream::connect(path) +} + +pub(crate) fn send_request(stream: &mut UnixStream, request: &ClientMessage) -> Result<()> { + let json = serde_json::to_vec(request).context("encoding a daemon request")?; + write_frame(stream, &Frame::control(json)).context("sending a daemon request")?; + stream.flush().context("flushing a daemon request") +} + +/// Send one request and read exactly one framed server response. +pub(crate) fn request( + path: &Path, + request: &ClientMessage, + timeout: Duration, +) -> Result { + let mut stream = connect(path) + .with_context(|| format!("connecting to the daemon socket at {}", path.display()))?; + send_request(&mut stream, request)?; + stream + .set_read_timeout(Some(timeout)) + .context("setting the one-shot daemon response timeout")?; + let frame = read_frame(&mut stream) + .context("wire error while reading the daemon response")? + .ok_or_else(|| anyhow::anyhow!("wire error: daemon closed before sending a response"))?; + if frame.kind != FrameKind::Control { + bail!( + "wire error: expected a control response, got {:?}", + frame.kind + ); + } + serde_json::from_slice(&frame.payload).context("protocol error: malformed daemon response JSON") +} + +#[cfg(test)] +#[path = "one_shot_tests.rs"] +mod tests; diff --git a/src/daemon/one_shot_tests.rs b/src/daemon/one_shot_tests.rs new file mode 100644 index 00000000..e30bd9ff --- /dev/null +++ b/src/daemon/one_shot_tests.rs @@ -0,0 +1,86 @@ +use super::*; +use crate::daemon::protocol::{ClientMessage, DaemonStatus, ServerMessage}; +use crate::daemon::socket::DaemonSocket; +use std::io::Write; +use std::thread; + +#[test] +fn one_shot_request_uses_the_configured_endpoint_and_reads_one_typed_response() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("status.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let frame = read_frame(&mut stream).unwrap().unwrap(); + let message: ClientMessage = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(message, ClientMessage::Status {}); + let status = DaemonStatus { + pid: 7, + version: "test".into(), + started_at_unix_ms: Ok(1), + uptime_ms: 2, + endpoint: Ok("status.sock".into()), + repositories: vec![], + attached_clients: vec![], + }; + let response = ServerMessage::Status { status }; + write_frame( + &mut stream, + &Frame::control(serde_json::to_vec(&response).unwrap()), + ) + .unwrap(); + stream.flush().unwrap(); + }); + + let response = request( + &path, + &ClientMessage::Status {}, + std::time::Duration::from_secs(1), + ) + .unwrap(); + assert!(matches!(response, ServerMessage::Status { .. })); + server.join().unwrap(); +} + +#[test] +fn a_terminal_response_is_a_wire_error() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("status.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + let listener = socket.listener().try_clone().unwrap(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_frame(&mut stream).unwrap(); + write_frame(&mut stream, &Frame::terminal(vec![1])).unwrap(); + stream.flush().unwrap(); + }); + let error = request( + &path, + &ClientMessage::Status {}, + std::time::Duration::from_secs(1), + ) + .unwrap_err(); + assert!(error.to_string().contains("wire error"), "{error:#}"); + server.join().unwrap(); +} + +#[cfg(unix)] +#[test] +fn unix_endpoint_override_uses_the_unix_transport_seam() { + endpoint_override_is_a_path(); +} + +#[cfg(windows)] +#[test] +fn windows_endpoint_override_uses_the_uds_transport_seam() { + endpoint_override_is_a_path(); +} + +fn endpoint_override_is_a_path() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("custom.sock"); + let socket = DaemonSocket::bind(&path).unwrap(); + assert!(connect(&path).is_ok()); + drop(socket); +} diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 34ec02fe..3427d29a 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -9,15 +9,20 @@ use crate::session::terminal::frame::{ use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; +mod status; +pub use status::{DaemonStatus, RepositoryStatus, StatusUnavailable, StatusUnavailableReason}; + /// A request from an attached client. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ClientMessage { /// First message on a connection. The daemon answers with [`ServerMessage::Hello`]. Hello { /// The client's build, so a mismatch is reported rather than acted on. version: String, }, + /// Read daemon-owned runtime facts without attaching to the session. + Status {}, ListRepos, /// Open a repository, or focus it if it is already open. OpenRepo { @@ -62,7 +67,7 @@ pub enum ClientMessage { /// A message from the daemon. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum ServerMessage { /// Answer to [`ClientMessage::Hello`], naming the daemon's build. Hello { @@ -72,6 +77,9 @@ pub enum ServerMessage { /// created by request and reported to everybody. client: u64, }, + /// One-shot answer to [`ClientMessage::Status`]. The connection closes + /// after this response and is never registered as an attached client. + Status { status: DaemonStatus }, /// The repository set, sent in answer to a list, open, close, or reorder. /// The whole set rather than a delta: another client may have changed it in /// between, and a delta applied to a stale list silently diverges. diff --git a/src/daemon/protocol/status.rs b/src/daemon/protocol/status.rs new file mode 100644 index 00000000..455ab57d --- /dev/null +++ b/src/daemon/protocol/status.rs @@ -0,0 +1,42 @@ +use crate::backend::PaneId; +use serde::{Deserialize, Serialize}; + +/// Authoritative runtime facts owned by the daemon and its session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DaemonStatus { + pub pid: u32, + pub version: String, + pub started_at_unix_ms: Result, + pub uptime_ms: u64, + /// The socket endpoint, or why it could not be represented as text. + pub endpoint: Result, + pub repositories: Vec, + /// Attach protocol client ids only. Terminal-hub connection ids are a + /// different namespace and deliberately do not appear here. + pub attached_clients: Vec, +} + +/// One open repository and the panes its daemon-owned hub currently owns. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RepositoryStatus { + pub id: String, + pub path: String, + pub panes: Vec, + pub pane_count: usize, +} + +/// A value the daemon could not represent, with a machine-readable reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StatusUnavailable { + pub reason: StatusUnavailableReason, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StatusUnavailableReason { + ClockBeforeUnixEpoch, + EndpointNotUnicode, +} diff --git a/src/daemon/protocol_tests.rs b/src/daemon/protocol_tests.rs index 8da5949d..3555b4e3 100644 --- a/src/daemon/protocol_tests.rs +++ b/src/daemon/protocol_tests.rs @@ -1,4 +1,7 @@ -use super::{ClientMessage, RepoSummary, ServerMessage, TerminalOutput, version}; +use super::{ + ClientMessage, DaemonStatus, RepoSummary, RepositoryStatus, ServerMessage, TerminalOutput, + version, +}; fn round_trip_client(message: &ClientMessage) -> ClientMessage { let json = serde_json::to_string(message).expect("encodes"); @@ -16,6 +19,7 @@ fn every_client_message_survives_the_round_trip() { ClientMessage::Hello { version: "0.1.0".into(), }, + ClientMessage::Status {}, ClientMessage::ListRepos, ClientMessage::OpenRepo { path: "/w/repo".into(), @@ -49,6 +53,22 @@ fn every_server_message_survives_the_round_trip() { version: "0.1.0".into(), client: 3, }, + ServerMessage::Status { + status: DaemonStatus { + pid: 42, + version: "0.1.0".into(), + started_at_unix_ms: Ok(123), + uptime_ms: 7, + endpoint: Ok("/tmp/nightcrow.sock".into()), + repositories: vec![RepositoryStatus { + id: "r1".into(), + path: "/w/repo".into(), + panes: vec![3], + pane_count: 1, + }], + attached_clients: vec![9], + }, + }, ServerMessage::Repos { repos: vec![RepoSummary { id: "r1".into(), @@ -90,6 +110,53 @@ fn a_message_missing_a_required_field_is_refused() { assert!(serde_json::from_str::(r#"{"type":"open_repo"}"#).is_err()); } +#[test] +fn status_rejects_unknown_request_fields_and_missing_response_fields() { + assert!(serde_json::from_str::(r#"{"type":"status","pid":1}"#).is_err()); + let missing_uptime = r#"{ + "type":"status","status":{"pid":1,"version":"0.1.0", + "started_at_unix_ms":{"Ok":1},"endpoint":"/tmp/d.sock", + "repositories":[],"attached_clients":[]}} + "#; + assert!(serde_json::from_str::(missing_uptime).is_err()); +} + +#[test] +fn an_unavailable_endpoint_reason_survives_the_status_round_trip() { + let status = DaemonStatus { + pid: 1, + version: version(), + started_at_unix_ms: Ok(0), + uptime_ms: 0, + endpoint: Err(super::StatusUnavailable { + reason: super::StatusUnavailableReason::EndpointNotUnicode, + }), + repositories: vec![], + attached_clients: vec![], + }; + assert_eq!( + round_trip_server(&ServerMessage::Status { + status: status.clone() + }), + ServerMessage::Status { status } + ); +} + +#[test] +fn existing_wire_messages_keep_their_shape() { + assert_eq!( + serde_json::to_string(&ClientMessage::Hello { + version: "0.1.0".into() + }) + .unwrap(), + r#"{"type":"hello","version":"0.1.0"}"# + ); + assert!(matches!( + serde_json::from_str::(r#"{"type":"list_repos"}"#), + Ok(ClientMessage::ListRepos) + )); +} + /// A reload carries nothing on the way out: the file on the daemon's disk is the /// request. An encoding that admitted a payload would be a client reconfiguring /// the session from something it made up. diff --git a/src/daemon/requests.rs b/src/daemon/requests.rs index d4881270..bf453d74 100644 --- a/src/daemon/requests.rs +++ b/src/daemon/requests.rs @@ -4,7 +4,7 @@ //! one record of what they have been told. use super::frame::{FrameKind, encode_server, read_frame}; -use super::protocol::{ClientMessage, ServerMessage, version}; +use super::protocol::{ClientMessage, ServerMessage}; use super::serve::Session; use super::transport::UnixStream; use crate::session::{self, CloseError, OpenError}; @@ -42,21 +42,11 @@ pub(super) fn read_requests(mut stream: UnixStream, id: u64, session: &Session) fn handle(message: ClientMessage, id: u64, session: &Session) { let state = &session.state; match message { - ClientMessage::Hello { version: client } => { - let daemon = version(); - let reply = if client == daemon { - ServerMessage::Hello { - version: daemon, - client: id, - } - } else { - // Reported, not refused: the two ship in one binary, so a - // mismatch means two builds are running at once. - ServerMessage::Error { - message: format!("client is {client}, daemon is {daemon}"), - } - }; - session.clients.send_to(id, encode_reply(&reply)); + ClientMessage::Hello { .. } => { + refuse(id, session, "hello is only valid as the first request") + } + ClientMessage::Status {} => { + refuse(id, session, "status is only valid as the first request") } // Answered to the asker alone (nothing changed), but not from here — // the set is sent from one place, in session-change order. This records diff --git a/src/daemon/serve.rs b/src/daemon/serve.rs index 3120f322..43de5d80 100644 --- a/src/daemon/serve.rs +++ b/src/daemon/serve.rs @@ -1,6 +1,7 @@ //! The daemon's accept loop: one attached client, two threads. The daemon -//! speaks unprompted — the session is shared — so a client gets a reader -//! (blocked on the socket) and a writer (draining that client's queue). +//! requires `Hello` as the first frame, then speaks unprompted because the +//! session is shared. An attached client gets a reader (blocked on the socket) +//! and a writer (draining that client's queue); a status query gets neither. //! //! Threaded like the viewer's accept loop but with a lower ceiling: a client //! here is a person at a terminal, not a browser tab. @@ -8,14 +9,13 @@ use anyhow::Context; use super::clients::AttachedClients; -use super::frame::write_frame; use super::terminals::TerminalBridges; -use super::transport::{UnixListener, UnixStream}; +use super::transport::UnixListener; use crate::platform::signals::Shutdown; use crate::session; use crate::session::SessionState; use std::collections::HashMap; -use std::io::Write; +use std::path::Path; use std::sync::mpsc::SyncSender; use std::sync::{Arc, Mutex}; @@ -23,6 +23,14 @@ use std::sync::{Arc, Mutex}; /// this is generous for the real case while still bounding a client stuck in a /// reconnect loop. pub const MAX_ATTACHED_CLIENTS: usize = 16; +/// Maximum number of sockets waiting for their first protocol frame. Kept as +/// a separate bound from the attached-client registry because one-shot status +/// and stop requests never become attached clients. +pub const MAX_PRE_ATTACH_CONNECTIONS: usize = 16; + +mod admission; +mod connection; +mod pre_attach; /// Everything the connection threads share. pub struct Session { @@ -40,9 +48,16 @@ pub struct Session { /// Signals the main thread to stop. Sent by the `Shutdown` client message /// handler, and also by the signal-forwarding thread in `cli.rs`. pub(super) shutdown_tx: SyncSender, + pub(super) metadata: super::status::DaemonMetadata, + admission: Arc, } impl Session { + #[cfg(test)] + pub(super) fn pre_attach_active(&self) -> usize { + self.admission.active() + } + /// Bring every attached client's subscriptions in line with `repos`. /// Oldest client first: subscribing takes a repository's pane sizing (the /// hub gives it to the newest connection), so in ascending id order the @@ -75,6 +90,7 @@ impl Session { /// [`DaemonSocket`]: super::socket::DaemonSocket pub fn start( state: Arc, + endpoint: &Path, shutdown_tx: SyncSender, ) -> anyhow::Result> { let session = Arc::new(Session { @@ -83,6 +99,10 @@ pub fn start( bridges: Mutex::new(HashMap::new()), nudge: Arc::new(super::watch::Nudge::default()), shutdown_tx, + metadata: super::status::DaemonMetadata::capture(endpoint), + admission: Arc::new(admission::PreAttachAdmission::new( + MAX_PRE_ATTACH_CONNECTIONS, + )), }); // The only sender of the served set, so clients are told it in one order // and changes made through the browser reach them at all. Started outside @@ -108,84 +128,14 @@ pub fn start( pub fn serve(listener: UnixListener, session: Arc) { for stream in listener.incoming() { let Ok(stream) = stream else { continue }; + let Some(permit) = session.admission.try_reserve() else { + tracing::debug!("daemon: refusing a connection over the pre-attach cap"); + continue; + }; let session = Arc::clone(&session); let _ = std::thread::Builder::new() .name("nightcrow-attach".into()) - .spawn(move || attach(stream, &session)); - } -} - -/// Serve one client for as long as it stays attached. -fn attach(stream: UnixStream, session: &Session) { - let Ok(write_half) = stream.try_clone() else { - tracing::debug!("daemon: could not split an attaching client's socket"); - return; - }; - // A third handle, so the set can end this connection if the client stops - // draining — the other two are blocked in `read` and `write`. - let Ok(hangup) = stream.try_clone() else { - tracing::debug!("daemon: could not split an attaching client's socket"); - return; - }; - let Some((id, queue)) = session.clients.try_connect(hangup, MAX_ATTACHED_CLIENTS) else { - // Dropped rather than answered: writing a refusal here would let one - // stalled client hold up every attach behind it. - tracing::debug!("daemon: refusing an attach over the client cap"); - return; - }; - let bridges = Arc::new(Mutex::new(TerminalBridges::new( - id, - Arc::clone(&session.clients), - ))); - session - .bridges - .lock() - .expect("attach bridges poisoned") - .insert(id, Arc::clone(&bridges)); - - // The writer owns its half outright, so the reader below can stay blocked - // in `read` while frames go out. - let writer = std::thread::Builder::new() - .name("nightcrow-attach-tx".into()) - .spawn(move || { - let mut out = write_half; - for frame in queue { - if write_frame(&mut out, &frame).is_err() || out.flush().is_err() { - break; - } - } - }); - - // Subscribed before the watcher can reach this client, so every open - // repository's panes are already streaming when the client learns the - // repositories exist. - bridges.lock().expect("client bridges poisoned").follow( - &session::list_session_repos(&session.state), - session.state.catalog(), - ); - // The set itself is sent by the watcher, to which this client is already - // registered as owed one — that is what keeps a client's frames in the - // order the session changed (see `watch::watch`). - session.nudge.poke(); - - if let Err(err) = super::requests::read_requests(stream, id, session) { - // Expected on detach: the client closes mid-read. Logged at debug - // because a person quitting is not a fault. - tracing::debug!(%err, "daemon: attached client ended"); - } - // Drops this client's sender, which ends the writer draining it, and its - // subscriptions, which stops the threads relaying its terminals. - session - .bridges - .lock() - .expect("attach bridges poisoned") - .remove(&id); - session.clients.disconnect(id); - if let Ok(writer) = writer { - crate::platform::threading::try_timed_join( - writer, - crate::platform::threading::REAP_TIMEOUT, - ); + .spawn(move || connection::run(stream, &session, permit)); } } diff --git a/src/daemon/serve/admission.rs b/src/daemon/serve/admission.rs new file mode 100644 index 00000000..ddb2de1b --- /dev/null +++ b/src/daemon/serve/admission.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Bounded admission for sockets that have not identified themselves yet. +/// It is separate from attached clients because status and stop are one-shot +/// requests and must never appear in the attached-client count. +pub(super) struct PreAttachAdmission { + active: AtomicUsize, + limit: usize, +} + +impl PreAttachAdmission { + pub(super) fn new(limit: usize) -> Self { + assert!(limit > 0, "pre-attach admission limit must be positive"); + Self { + active: AtomicUsize::new(0), + limit, + } + } + + pub(super) fn try_reserve(self: &Arc) -> Option { + let mut active = self.active.load(Ordering::Acquire); + loop { + if active >= self.limit { + return None; + } + match self.active.compare_exchange_weak( + active, + active + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(Permit(Arc::clone(self))), + Err(observed) => active = observed, + } + } + } + + #[cfg(test)] + pub(super) fn active(&self) -> usize { + self.active.load(Ordering::Acquire) + } +} + +/// A pre-attach slot that releases itself on every return, including errors +/// and thread-spawn failures. +pub(super) struct Permit(Arc); + +impl Drop for Permit { + fn drop(&mut self) { + self.0.active.fetch_sub(1, Ordering::AcqRel); + } +} + +#[cfg(test)] +#[path = "admission_tests.rs"] +mod tests; diff --git a/src/daemon/serve/admission_tests.rs b/src/daemon/serve/admission_tests.rs new file mode 100644 index 00000000..cb90f043 --- /dev/null +++ b/src/daemon/serve/admission_tests.rs @@ -0,0 +1,20 @@ +use super::PreAttachAdmission; +use std::sync::Arc; + +#[test] +fn stalled_no_frame_peers_are_bounded_and_raii_permits_release() { + let admission = Arc::new(PreAttachAdmission::new(2)); + let partial_frame_peer = admission.try_reserve().expect("first slot"); + let no_frame_peer = admission.try_reserve().expect("second slot"); + + assert_eq!(admission.active(), 2); + assert!(admission.try_reserve().is_none(), "the cap is atomic"); + + drop(partial_frame_peer); + assert_eq!(admission.active(), 1); + let returned_slot = admission.try_reserve().expect("a released slot returns"); + drop(no_frame_peer); + assert_eq!(admission.active(), 1); + drop(returned_slot); + assert_eq!(admission.active(), 0); +} diff --git a/src/daemon/serve/connection.rs b/src/daemon/serve/connection.rs new file mode 100644 index 00000000..44bc3e35 --- /dev/null +++ b/src/daemon/serve/connection.rs @@ -0,0 +1,128 @@ +use super::{MAX_ATTACHED_CLIENTS, Session, admission, pre_attach}; +use crate::daemon::HANDSHAKE_TIMEOUT; +use crate::daemon::frame::write_frame; +use crate::daemon::terminals::TerminalBridges; +use crate::daemon::transport::UnixStream; +use std::io::Write; +use std::sync::{Arc, Mutex}; + +/// Serve one client for as long as it stays attached. +pub(super) fn run(mut stream: UnixStream, session: &Session, permit: admission::Permit) { + if let Err(err) = stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT)) { + tracing::debug!(%err, "daemon: could not set the pre-attach timeout"); + return; + } + let client_version = match pre_attach::read(&mut stream, session) { + Ok(Some(version)) => version, + Ok(None) => return, + Err(err) => { + tracing::debug!(%err, "daemon: pre-attach client ended"); + return; + } + }; + if client_version != crate::daemon::protocol::version() { + send_version_mismatch(&mut stream, &client_version); + return; + } + attach(stream, session, permit); +} + +/// Complete a matching Hello transition while the pre-attach permit is still +/// held. The permit is released only after the attached registry has inserted +/// its slot, so a connection always belongs to one admission set or the other. +fn attach(stream: UnixStream, session: &Session, permit: admission::Permit) { + if let Err(err) = stream.set_read_timeout(None) { + tracing::debug!(%err, "daemon: could not clear the pre-attach timeout"); + return; + } + let Ok(write_half) = stream.try_clone() else { + tracing::debug!("daemon: could not split an attaching client's socket"); + return; + }; + let Ok(hangup) = stream.try_clone() else { + tracing::debug!("daemon: could not split an attaching client's socket"); + return; + }; + let Some((id, queue)) = session.clients.try_connect(hangup, MAX_ATTACHED_CLIENTS) else { + tracing::debug!("daemon: refusing an attach over the client cap"); + return; + }; + // Do not release the pre-attach permit until the attached registry's + // atomic cap check and insertion have succeeded. + drop(permit); + let bridges = Arc::new(Mutex::new(TerminalBridges::new( + id, + Arc::clone(&session.clients), + ))); + session + .bridges + .lock() + .expect("attach bridges poisoned") + .insert(id, Arc::clone(&bridges)); + send_hello(session, id); + + let writer = std::thread::Builder::new() + .name("nightcrow-attach-tx".into()) + .spawn(move || write_queued(write_half, queue)); + + bridges.lock().expect("client bridges poisoned").follow( + &crate::session::list_session_repos(&session.state), + session.state.catalog(), + ); + session.nudge.poke(); + + if let Err(err) = crate::daemon::requests::read_requests(stream, id, session) { + tracing::debug!(%err, "daemon: attached client ended"); + } + session + .bridges + .lock() + .expect("attach bridges poisoned") + .remove(&id); + session.clients.disconnect(id); + if let Ok(writer) = writer { + crate::platform::threading::try_timed_join( + writer, + crate::platform::threading::REAP_TIMEOUT, + ); + } +} + +fn send_hello(session: &Session, id: u64) { + let hello = crate::daemon::protocol::ServerMessage::Hello { + version: crate::daemon::protocol::version(), + client: id, + }; + session.clients.send_to( + id, + crate::daemon::frame::encode_server(&hello, "hello", "hello could not be encoded"), + ); +} + +fn send_version_mismatch(stream: &mut UnixStream, client_version: &str) { + let daemon_version = crate::daemon::protocol::version(); + let error = crate::daemon::protocol::ServerMessage::Error { + message: format!("client is {client_version}, daemon is {daemon_version}"), + }; + let frame = crate::daemon::frame::encode_server( + &error, + "version mismatch", + "version mismatch could not be encoded", + ); + if let Err(err) = write_frame(stream, &frame) { + tracing::debug!(%err, "daemon: could not send version mismatch"); + } else if let Err(err) = stream.flush() { + tracing::debug!(%err, "daemon: could not send version mismatch"); + } +} + +fn write_queued( + mut out: UnixStream, + queue: std::sync::mpsc::Receiver, +) { + for frame in queue { + if write_frame(&mut out, &frame).is_err() || out.flush().is_err() { + break; + } + } +} diff --git a/src/daemon/serve/pre_attach.rs b/src/daemon/serve/pre_attach.rs new file mode 100644 index 00000000..f44ff1f8 --- /dev/null +++ b/src/daemon/serve/pre_attach.rs @@ -0,0 +1,68 @@ +use super::Session; +use crate::daemon::frame::{FrameKind, encode_server, read_frame, write_frame}; +use crate::daemon::protocol::{ClientMessage, ServerMessage}; +use crate::daemon::transport::UnixStream; +use anyhow::Result; +use std::io::Write; + +/// Read the only frame allowed before attachment. `Some(version)` admits the +/// connection to the stateful attach path; every other outcome is complete. +pub(super) fn read(stream: &mut UnixStream, session: &Session) -> Result> { + let Some(frame) = read_frame(stream)? else { + return Ok(None); + }; + if frame.kind != FrameKind::Control { + reply_and_close( + stream, + error("first frame must be hello, status, or shutdown"), + )?; + return Ok(None); + } + let message = match serde_json::from_slice::(&frame.payload) { + Ok(message) => message, + Err(err) => { + reply_and_close(stream, error(&format!("unreadable first request: {err}")))?; + return Ok(None); + } + }; + match message { + ClientMessage::Hello { version } => Ok(Some(version)), + ClientMessage::Status {} => { + let status = session.metadata.snapshot(session); + reply_and_close(stream, ServerMessage::Status { status })?; + Ok(None) + } + ClientMessage::Shutdown => { + // Stop is also one-shot: it must keep working with the handshake + // now required by stateful attach, without registering a client. + let _ = session + .shutdown_tx + .send(crate::platform::signals::Shutdown::Terminate); + Ok(None) + } + _ => { + reply_and_close( + stream, + error("first request must be hello, status, or shutdown"), + )?; + Ok(None) + } + } +} + +fn reply_and_close(stream: &mut UnixStream, message: ServerMessage) -> Result<()> { + let frame = encode_server( + &message, + "pre-attach reply", + "pre-attach reply could not be encoded", + ); + write_frame(stream, &frame)?; + stream.flush()?; + Ok(()) +} + +fn error(message: &str) -> ServerMessage { + ServerMessage::Error { + message: message.to_string(), + } +} diff --git a/src/daemon/serve_tests/accent.rs b/src/daemon/serve_tests/accent.rs index c819da00..e24fbe03 100644 --- a/src/daemon/serve_tests/accent.rs +++ b/src/daemon/serve_tests/accent.rs @@ -35,6 +35,7 @@ fn the_accent_comes_with_the_first_set_a_client_is_given() { assert_eq!(picker.next_accent(), 2); let mut arriving = Client::attach_raw(daemon.path()); + arriving.hello(); assert_eq!(arriving.next_accent(), 2); drop(repo); diff --git a/src/daemon/serve_tests/harness.rs b/src/daemon/serve_tests/harness.rs index 90d1d9fe..eb9614d4 100644 --- a/src/daemon/serve_tests/harness.rs +++ b/src/daemon/serve_tests/harness.rs @@ -11,6 +11,8 @@ pub(super) struct TestDaemon { /// The session itself, so a test can change it the way the browser does — /// through the session functions, with no attach connection involved. state: std::sync::Arc, + pub(super) session: std::sync::Arc, + pub(super) shutdown_rx: std::sync::mpsc::Receiver, } impl TestDaemon { @@ -36,10 +38,17 @@ pub(super) fn daemon(dir: &tempfile::TempDir, repos: &[String]) -> TestDaemon { // them last. let state = crate::test_util::session_state(repos, dir.path()); let served = std::sync::Arc::clone(&state); - let (shutdown_tx, _shutdown_rx) = std::sync::mpsc::sync_channel(1); - let session = crate::daemon::serve::start(served, shutdown_tx).expect("starts the watcher"); - std::thread::spawn(move || crate::daemon::serve::serve(listener, session)); - TestDaemon { socket, state } + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::sync_channel(1); + let session = crate::daemon::serve::start(served, socket.path(), shutdown_tx) + .expect("starts the watcher"); + let serving = std::sync::Arc::clone(&session); + std::thread::spawn(move || crate::daemon::serve::serve(listener, serving)); + TestDaemon { + socket, + state, + session, + shutdown_rx, + } } /// How long a helper waits on the socket for the frame it is after. @@ -64,6 +73,7 @@ pub(super) fn decodes_to_terminal(frame: &Frame) -> bool { /// A client attached to the daemon at `path`. pub(super) struct Client { pub(super) stream: UnixStream, + id: Option, /// Frames read while looking for a different one. /// /// The real client routes everything it reads rather than dropping what it @@ -76,9 +86,10 @@ pub(super) struct Client { } impl Client { - /// Attach and consume the repository set the daemon sends unprompted. + /// Complete the hello handshake and consume the repository set. pub(super) fn attach(path: &std::path::Path) -> Self { let mut client = Self::attach_raw(path); + client.hello(); client.next_repos(); client } @@ -87,6 +98,7 @@ impl Client { pub(super) fn attach_raw(path: &std::path::Path) -> Self { Self { stream: UnixStream::connect(path).expect("attaches"), + id: None, pending: std::collections::VecDeque::new(), } } @@ -151,9 +163,13 @@ impl Client { /// Complete the handshake and return the id the daemon knows this /// connection by. pub(super) fn hello(&mut self) -> u64 { + if let Some(id) = self.id { + return id; + } self.send(ClientMessage::Hello { version: version() }); for _ in 0..64 { if let ServerMessage::Hello { client, .. } = self.next() { + self.id = Some(client); return client; } } diff --git a/src/daemon/serve_tests/mod.rs b/src/daemon/serve_tests/mod.rs index fae6e7b4..c0479a8d 100644 --- a/src/daemon/serve_tests/mod.rs +++ b/src/daemon/serve_tests/mod.rs @@ -10,4 +10,5 @@ mod harness_terminal; mod other_transport; mod reload; mod session; +mod status; mod terminals; diff --git a/src/daemon/serve_tests/session.rs b/src/daemon/serve_tests/session.rs index 117c83f9..d382af9c 100644 --- a/src/daemon/serve_tests/session.rs +++ b/src/daemon/serve_tests/session.rs @@ -7,7 +7,7 @@ use std::io::Write; fn a_client_that_says_hello_is_answered_with_the_daemon_version() { let dir = tempfile::TempDir::new().unwrap(); let daemon = daemon(&dir, &[]); - let mut client = Client::attach(daemon.path()); + let mut client = Client::attach_raw(daemon.path()); let answer = client.ask(ClientMessage::Hello { version: version() }); @@ -37,7 +37,8 @@ fn a_version_mismatch_is_reported_rather_than_ignored() { // one side cannot decode. let dir = tempfile::TempDir::new().unwrap(); let daemon = daemon(&dir, &[]); - let mut client = Client::attach(daemon.path()); + let before_repos = daemon.state().status_snapshot(); + let mut client = Client::attach_raw(daemon.path()); let answer = client.ask(ClientMessage::Hello { version: "0.0.1-from-another-build".into(), @@ -50,6 +51,33 @@ fn a_version_mismatch_is_reported_rather_than_ignored() { } other => panic!("expected a mismatch report, got {other:?}"), } + client + .stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .expect("sets a timeout"); + assert!( + read_frame(&mut client.stream) + .expect("the mismatched connection closes") + .is_none() + ); + let request = serde_json::to_vec(&ClientMessage::OpenRepo { + path: "not-used-after-mismatch".into(), + }) + .expect("encodes"); + let _ = write_frame(&mut client.stream, &Frame::control(request)); + assert_eq!(daemon.state().status_snapshot(), before_repos); + assert_eq!(daemon.session.clients.len(), 0); + assert!(daemon.session.bridges.lock().unwrap().is_empty()); + assert_eq!( + daemon + .state() + .catalog() + .entries() + .iter() + .map(|entry| entry.terminals.client_count()) + .sum::(), + 0 + ); } #[test] @@ -154,6 +182,7 @@ fn attaching_serves_the_repository_set_before_anything_is_asked() { let daemon = daemon(&dir, std::slice::from_ref(&path)); let mut client = Client::attach_raw(daemon.path()); + client.hello(); assert_eq!(repo_paths(&client.next_repos()), vec![resolved(&path)]); drop(repo); diff --git a/src/daemon/serve_tests/status.rs b/src/daemon/serve_tests/status.rs new file mode 100644 index 00000000..5aa44157 --- /dev/null +++ b/src/daemon/serve_tests/status.rs @@ -0,0 +1,164 @@ +use super::harness::*; +use crate::daemon::frame::{Frame, read_frame, write_frame}; +use crate::daemon::protocol::{ClientMessage, ServerMessage, version}; +use crate::daemon::transport::UnixStream; +use std::io::Write; + +fn one_shot(path: &std::path::Path, frame: Frame) -> (ServerMessage, UnixStream) { + let mut stream = UnixStream::connect(path).expect("connects"); + write_frame(&mut stream, &frame).expect("writes first frame"); + stream.flush().expect("flushes first frame"); + let frame = read_frame(&mut stream) + .expect("reads response") + .expect("daemon responds"); + let message = serde_json::from_slice(&frame.payload).expect("response decodes"); + (message, stream) +} + +fn status_frame() -> Frame { + Frame::control(serde_json::to_vec(&ClientMessage::Status {}).unwrap()) +} + +#[test] +fn status_is_authoritative_and_does_not_attach_or_mutate_the_session() { + let (repo, path) = crate::test_util::make_repo(); + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, std::slice::from_ref(&path)); + let mut attached = Client::attach(daemon.path()); + let attached_id = attached.hello(); + + let before = diagnostics(&daemon); + let (answer, mut stream) = one_shot(daemon.path(), status_frame()); + let ServerMessage::Status { status } = answer else { + panic!("expected status response, got {answer:?}"); + }; + + assert_eq!(status.pid, std::process::id()); + assert_eq!(status.version, version()); + assert_eq!( + status.endpoint.as_deref(), + Ok(daemon.path().to_str().expect("test path is Unicode")) + ); + assert_eq!(status.attached_clients, vec![attached_id]); + assert_eq!(status.repositories.len(), 1); + assert_eq!(status.repositories[0].path, resolved(&path)); + assert_eq!( + status.repositories[0].pane_count, + status.repositories[0].panes.len() + ); + assert!(status.started_at_unix_ms.is_ok()); + assert_eq!(diagnostics(&daemon), before); + assert!(read_frame(&mut stream).expect("clean close").is_none()); + drop(repo); +} + +#[test] +fn a_non_handshake_first_request_is_refused_without_attaching() { + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, &[]); + let frame = Frame::control(serde_json::to_vec(&ClientMessage::ListRepos).unwrap()); + + let (answer, _) = one_shot(daemon.path(), frame); + + assert!(matches!(answer, ServerMessage::Error { .. })); + assert_eq!(daemon.session.clients.len(), 0); + assert!(daemon.session.bridges.lock().unwrap().is_empty()); +} + +#[test] +fn an_invalid_first_request_is_refused_without_attaching() { + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, &[]); + + let (answer, _) = one_shot(daemon.path(), Frame::control(b"{not json".to_vec())); + + assert!(matches!(answer, ServerMessage::Error { .. })); + assert_eq!(daemon.session.clients.len(), 0); +} + +#[test] +fn stop_request_is_still_accepted_before_attach() { + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, &[]); + + crate::cli::run_stop(Some(daemon.path().to_path_buf())).expect("stop request succeeds"); + + assert_eq!( + daemon + .shutdown_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .expect("the daemon receives the stop signal"), + crate::platform::signals::Shutdown::Terminate + ); + assert_eq!(daemon.session.clients.len(), 0); + assert!(daemon.session.bridges.lock().unwrap().is_empty()); +} + +#[test] +fn matching_hello_transitions_from_pre_attach_to_one_attached_client() { + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, &[]); + let mut client = Client::attach_raw(daemon.path()); + + client.hello(); + + assert_eq!(daemon.session.pre_attach_active(), 0); + assert_eq!(daemon.session.clients.len(), 1); +} + +#[test] +fn attached_cap_rejection_returns_the_pre_attach_permit() { + let dir = tempfile::TempDir::new().unwrap(); + let daemon = daemon(&dir, &[]); + let mut clients = Vec::new(); + for _ in 0..crate::daemon::serve::MAX_ATTACHED_CLIENTS { + clients.push(Client::attach(daemon.path())); + } + assert_eq!( + daemon.session.clients.len(), + crate::daemon::serve::MAX_ATTACHED_CLIENTS + ); + assert_eq!(daemon.session.pre_attach_active(), 0); + + let mut rejected = Client::attach_raw(daemon.path()); + rejected.send(ClientMessage::Hello { version: version() }); + rejected + .stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .expect("sets a timeout"); + assert!( + read_frame(&mut rejected.stream) + .expect("the capped connection closes") + .is_none() + ); + assert_eq!(daemon.session.pre_attach_active(), 0); + assert_eq!( + daemon.session.clients.len(), + crate::daemon::serve::MAX_ATTACHED_CLIENTS + ); +} + +#[derive(Debug, PartialEq, Eq)] +struct Diagnostics { + attached: usize, + bridges: usize, + terminal_clients: usize, + repositories: Vec, + active: Option, + accent: usize, +} + +fn diagnostics(daemon: &TestDaemon) -> Diagnostics { + let entries = daemon.state().catalog().entries(); + Diagnostics { + attached: daemon.session.clients.len(), + bridges: daemon.session.bridges.lock().unwrap().len(), + terminal_clients: entries + .iter() + .map(|entry| entry.terminals.client_count()) + .sum(), + repositories: daemon.state().status_snapshot(), + active: crate::session::active_repo(daemon.state()), + accent: crate::session::accent(daemon.state()), + } +} diff --git a/src/daemon/serve_tests/terminals.rs b/src/daemon/serve_tests/terminals.rs index 7a3ad391..fe17c79f 100644 --- a/src/daemon/serve_tests/terminals.rs +++ b/src/daemon/serve_tests/terminals.rs @@ -30,6 +30,7 @@ fn attaching_subscribes_to_the_terminals_of_every_open_repository() { let daemon = daemon(&dir, std::slice::from_ref(&path)); let mut client = Client::attach_raw(daemon.path()); + client.hello(); let (id, _) = client.next_terminal_event(); assert!(!id.is_empty(), "the event says which repository it is for"); diff --git a/src/daemon/status.rs b/src/daemon/status.rs new file mode 100644 index 00000000..d726dfad --- /dev/null +++ b/src/daemon/status.rs @@ -0,0 +1,69 @@ +use super::protocol::{DaemonStatus, RepositoryStatus, StatusUnavailable, StatusUnavailableReason}; +use super::serve::Session; +use std::path::Path; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// Immutable process facts captured once when the attach session is created. +pub(super) struct DaemonMetadata { + pid: u32, + version: String, + started_at: SystemTime, + started_mono: Instant, + endpoint: Result, +} + +impl DaemonMetadata { + pub(super) fn capture(endpoint: &Path) -> Self { + Self { + pid: std::process::id(), + version: super::protocol::version(), + started_at: SystemTime::now(), + started_mono: Instant::now(), + endpoint: endpoint + .to_str() + .map(str::to_owned) + .ok_or(StatusUnavailable { + reason: StatusUnavailableReason::EndpointNotUnicode, + }), + } + } + + pub(super) fn snapshot(&self, session: &Session) -> DaemonStatus { + let repositories = session + .state + .status_snapshot() + .into_iter() + .map(|repo| RepositoryStatus { + pane_count: repo.panes.len(), + id: repo.id, + path: repo.path, + panes: repo.panes, + }) + .collect(); + DaemonStatus { + pid: self.pid, + version: self.version.clone(), + started_at_unix_ms: unix_millis(self.started_at), + uptime_ms: millis(self.started_mono.elapsed()), + endpoint: self.endpoint.clone(), + repositories, + attached_clients: session.clients.ids(), + } + } +} + +fn unix_millis(time: SystemTime) -> Result { + time.duration_since(UNIX_EPOCH) + .map(millis) + .map_err(|_| StatusUnavailable { + reason: StatusUnavailableReason::ClockBeforeUnixEpoch, + }) +} + +fn millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +#[path = "status_tests.rs"] +mod tests; diff --git a/src/daemon/status_tests.rs b/src/daemon/status_tests.rs new file mode 100644 index 00000000..583da698 --- /dev/null +++ b/src/daemon/status_tests.rs @@ -0,0 +1,25 @@ +use super::*; + +#[cfg(unix)] +#[test] +fn a_non_unicode_endpoint_is_reported_as_unavailable() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let path = std::path::PathBuf::from(OsString::from_vec(vec![b'd', b'.', 0xff])); + let metadata = DaemonMetadata::capture(&path); + assert_eq!( + metadata.endpoint, + Err(StatusUnavailable { + reason: StatusUnavailableReason::EndpointNotUnicode, + }) + ); +} + +#[cfg(windows)] +#[test] +fn a_unicode_endpoint_is_preserved_exactly() { + let path = std::path::Path::new(r"C:\nightcrow\한글.sock"); + let metadata = DaemonMetadata::capture(path); + assert_eq!(metadata.endpoint, Ok(path.to_str().unwrap().to_owned())); +} diff --git a/src/main.rs b/src/main.rs index 127dcfe8..68a1d8a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,9 @@ mod workspace; use anyhow::Result; use clap::Parser; -use crate::cli::{Cli, Commands, run_attach_detached, run_daemon, run_init, run_stop, run_update}; +use crate::cli::{ + Cli, Commands, run_attach_detached, run_daemon, run_init, run_status, run_stop, run_update, +}; /// Every path here runs to completion and returns; nothing in this process /// takes over the terminal. The session runs headless and `attach` is a @@ -42,6 +44,7 @@ fn main() -> Result<()> { Some(Commands::Attach) => run_attach_detached(), Some(Commands::Plugin { command }) => cli::plugin_cmd::run_plugin(command), Some(Commands::Stop { socket }) => run_stop(socket), + Some(Commands::Status { socket }) => run_status(socket), Some(Commands::Update { path, git }) => run_update(path, git), None => run_daemon(cli.exec, cli.port, cli.bind, cli.detach), } diff --git a/src/plugin/host.rs b/src/plugin/host.rs index b1e6279e..52f461db 100644 --- a/src/plugin/host.rs +++ b/src/plugin/host.rs @@ -5,13 +5,14 @@ //! make either block — the terminal hub calls them on the thread that also //! serves every pane. +use super::host_command::configure_command; use super::host_pump; use super::protocol::{PROTOCOL_VERSION, PluginCommand, PluginEvent, encode_event}; use crate::config::PluginConfig; use crate::platform::threading::{REAP_TIMEOUT, try_timed_join}; use anyhow::{Context, Result}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::path::Path; +use std::process::Child; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; use std::sync::{Arc, Mutex}; @@ -61,28 +62,6 @@ pub struct PluginHost { shut_down: bool, } -/// Keep a plugin from opening a console window of its own. -/// -/// A backgrounded session runs `DETACHED_PROCESS`, so it has no console to hand -/// down. Windows answers that by allocating a *new* console for a -/// console-subsystem child — one visible window per plugin, and a window the -/// user can close, which kills the plugin under it. Every pipe this child uses -/// is one the spawn opened, so it has nothing to show a console for. -/// -/// Unix inherits no console this way and needs no flag. -fn no_console_window(command: &mut Command) { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } - #[cfg(not(windows))] - { - let _ = command; - } -} - impl PluginHost { /// Launch `cfg.command` and start pumping. /// @@ -108,21 +87,7 @@ impl PluginHost { runtime_dir: Option<&Path>, depth: usize, ) -> Result { - let program = resolve_program(&cfg.command, plugin_dir); - let mut command = Command::new(&program); - command - .args(&cfg.args) - .envs(&cfg.env) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - // After `cfg.env`: which hub a plugin belongs to is the host's to say, - // not something a config can point at another hub's socket. See - // `PLUGIN_RUNTIME_DIR_ENV`. - if let Some(dir) = runtime_dir { - command.env(crate::backend::identity::PLUGIN_RUNTIME_DIR_ENV, dir); - } - no_console_window(&mut command); + let (program, mut command) = configure_command(cfg, plugin_dir, runtime_dir); let mut child = command.spawn().with_context(|| { format!( "cannot launch plugin \"{}\" from {}", @@ -274,29 +239,6 @@ impl Drop for PluginHost { } } -/// See [`PluginHost::spawn`] for the order and why it is that way. -fn resolve_program(command: &str, plugin_dir: Option<&Path>) -> PathBuf { - if command.contains(std::path::MAIN_SEPARATOR) || command.contains('/') { - return PathBuf::from(command); - } - if let Some(dir) = plugin_dir { - let candidate = dir.join(command); - if candidate.is_file() { - return candidate; - } - // On Windows an installed plugin is stored as `name.exe` but - // configured as `name`; try the extension before PATH. - #[cfg(windows)] - { - let exe = dir.join(format!("{command}.exe")); - if exe.is_file() { - return exe; - } - } - } - PathBuf::from(command) -} - #[cfg(all(test, unix))] #[path = "host_tests.rs"] mod tests; diff --git a/src/plugin/host_command.rs b/src/plugin/host_command.rs new file mode 100644 index 00000000..a97c7d62 --- /dev/null +++ b/src/plugin/host_command.rs @@ -0,0 +1,74 @@ +//! Build the platform-specific command used to launch a plugin. + +use crate::config::PluginConfig; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +/// Resolve and configure a plugin command before it is spawned. +pub(super) fn configure_command( + cfg: &PluginConfig, + plugin_dir: Option<&Path>, + runtime_dir: Option<&Path>, +) -> (PathBuf, Command) { + let program = resolve_program(&cfg.command, plugin_dir); + let mut command = Command::new(&program); + command + .args(&cfg.args) + .envs(&cfg.env) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + // After `cfg.env`: which hub a plugin belongs to is the host's to say, + // not something a config can point at another hub's socket. See + // `PLUGIN_RUNTIME_DIR_ENV`. + if let Some(dir) = runtime_dir { + command.env(crate::backend::identity::PLUGIN_RUNTIME_DIR_ENV, dir); + } + no_console_window(&mut command); + (program, command) +} + +/// Keep a plugin from opening a console window of its own. +/// +/// A backgrounded session runs `DETACHED_PROCESS`, so it has no console to hand +/// down. Windows answers that by allocating a *new* console for a +/// console-subsystem child — one visible window per plugin, and a window the +/// user can close, which kills the plugin under it. Every pipe this child uses +/// is one the spawn opened, so it has nothing to show a console for. +/// +/// Unix inherits no console this way and needs no flag. +fn no_console_window(command: &mut Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + { + let _ = command; + } +} + +/// See [`super::host::PluginHost::spawn`] for the order and why it is that way. +fn resolve_program(command: &str, plugin_dir: Option<&Path>) -> PathBuf { + if command.contains(std::path::MAIN_SEPARATOR) || command.contains('/') { + return PathBuf::from(command); + } + if let Some(dir) = plugin_dir { + let candidate = dir.join(command); + if candidate.is_file() { + return candidate; + } + // On Windows an installed plugin is stored as `name.exe` but + // configured as `name`; try the extension before PATH. + #[cfg(windows)] + { + let exe = dir.join(format!("{command}.exe")); + if exe.is_file() { + return exe; + } + } + } + PathBuf::from(command) +} diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 8f7eb3cc..d272281f 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -30,6 +30,7 @@ mod guard_budget; mod guard_refusal; mod guard_text; mod guard_watch; +mod host_command; mod host_pump; pub use guard::{Approved, Guard, PaneFacts}; diff --git a/src/runtime/terminal/tests/mod.rs b/src/runtime/terminal/tests/mod.rs index e844d4e7..42d7e35a 100644 --- a/src/runtime/terminal/tests/mod.rs +++ b/src/runtime/terminal/tests/mod.rs @@ -3,6 +3,7 @@ use super::*; mod activity; mod common; mod lifecycle_tests; +mod panes_from_elsewhere_tests; mod poll_tests; mod recovery_tests; mod scroll_tests; diff --git a/src/runtime/terminal/tests/panes_from_elsewhere_tests.rs b/src/runtime/terminal/tests/panes_from_elsewhere_tests.rs new file mode 100644 index 00000000..31ea998e --- /dev/null +++ b/src/runtime/terminal/tests/panes_from_elsewhere_tests.rs @@ -0,0 +1,100 @@ +//! Tests for panes created by another client in a shared session. + +use super::common::*; +use crate::backend::BackendEvent; + +#[test] +fn a_pane_this_client_asked_for_takes_the_focus() { + let (mut state, _events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + state.create_pane_now().unwrap(); + + assert_eq!(state.panes.len(), 2); + assert_eq!(state.active, 1, "the pane just opened is the active one"); +} + +#[test] +fn a_pane_someone_else_opened_appears_without_stealing_the_focus() { + // Which pane a client is looking at is its own business. A pane opened + // in a browser tab must show up in the list and leave the cursor where + // the person here put it. + let (mut state, events) = state_with_event_queue(); + state.create_pane_now().unwrap(); + let mine = state.panes[0].id; + + events.borrow_mut().push(BackendEvent::Created { + pane: 99, + rows: 24, + cols: 80, + requested: false, + title: None, + }); + state.poll(); + + assert_eq!(state.panes.len(), 2); + assert_eq!(state.panes[1].id, 99); + assert_eq!( + state.active_pane_id(), + Some(mine), + "the active pane must not move" + ); +} + +#[test] +fn a_pane_reported_twice_is_only_taken_once() { + // A client can be told about a pane it already has — reconnecting to a + // session replays what is open. Adopting it again would duplicate the + // tab and orphan the first emulator. + let (mut state, events) = state_with_event_queue(); + for _ in 0..2 { + events.borrow_mut().push(BackendEvent::Created { + pane: 7, + rows: 24, + cols: 80, + requested: false, + title: None, + }); + state.poll(); + } + + assert_eq!(state.panes.len(), 1); +} + +#[test] +fn a_title_waits_for_the_pane_it_was_asked_for() { + // The label is chosen when the pane is requested and applied when it + // arrives, so a startup command keeps its name across the round trip. + let (mut state, _events) = state_with_event_queue(); + + state + .create_pane_with_now(Some("cargo test"), Some("tests")) + .unwrap(); + + assert_eq!(state.panes[0].title, "tests"); +} + +#[test] +fn a_pane_from_elsewhere_does_not_take_a_title_this_client_is_waiting_on() { + // The queue belongs to what this client asked for. Handing its label to + // someone else's pane would put the wrong name on both. + let (mut state, events) = state_with_event_queue(); + state + .create_pane_with(Some("cargo test"), Some("tests")) + .unwrap(); + + events.borrow_mut().push(BackendEvent::Created { + pane: 99, + rows: 24, + cols: 80, + requested: false, + title: None, + }); + state.poll(); + // Now the requested one arrives and claims the label it was given. + state.poll(); + + let theirs = state.panes.iter().find(|p| p.id == 99).expect("their pane"); + assert_ne!(theirs.title, "tests"); + let mine = state.panes.iter().find(|p| p.id != 99).expect("my pane"); + assert_eq!(mine.title, "tests"); +} diff --git a/src/runtime/terminal/tests/poll_tests.rs b/src/runtime/terminal/tests/poll_tests.rs index 3d1dbf13..2a69ddbc 100644 --- a/src/runtime/terminal/tests/poll_tests.rs +++ b/src/runtime/terminal/tests/poll_tests.rs @@ -200,106 +200,3 @@ fn a_pane_exit_marks_attention_after_removing_the_pane() { assert!(state.panes.is_empty()); assert!(state.has_unread_attention()); } - -/// A pane the backend reports without this client having asked for it — what -/// happens when another client on a shared session opens one. -mod panes_from_elsewhere { - use super::super::common::*; - use crate::backend::BackendEvent; - - #[test] - fn a_pane_this_client_asked_for_takes_the_focus() { - let (mut state, _events) = state_with_event_queue(); - state.create_pane_now().unwrap(); - state.create_pane_now().unwrap(); - - assert_eq!(state.panes.len(), 2); - assert_eq!(state.active, 1, "the pane just opened is the active one"); - } - - #[test] - fn a_pane_someone_else_opened_appears_without_stealing_the_focus() { - // Which pane a client is looking at is its own business. A pane opened - // in a browser tab must show up in the list and leave the cursor where - // the person here put it. - let (mut state, events) = state_with_event_queue(); - state.create_pane_now().unwrap(); - let mine = state.panes[0].id; - - events.borrow_mut().push(BackendEvent::Created { - pane: 99, - rows: 24, - cols: 80, - requested: false, - title: None, - }); - state.poll(); - - assert_eq!(state.panes.len(), 2); - assert_eq!(state.panes[1].id, 99); - assert_eq!( - state.active_pane_id(), - Some(mine), - "the active pane must not move" - ); - } - - #[test] - fn a_pane_reported_twice_is_only_taken_once() { - // A client can be told about a pane it already has — reconnecting to a - // session replays what is open. Adopting it again would duplicate the - // tab and orphan the first emulator. - let (mut state, events) = state_with_event_queue(); - for _ in 0..2 { - events.borrow_mut().push(BackendEvent::Created { - pane: 7, - rows: 24, - cols: 80, - requested: false, - title: None, - }); - state.poll(); - } - - assert_eq!(state.panes.len(), 1); - } - - #[test] - fn a_title_waits_for_the_pane_it_was_asked_for() { - // The label is chosen when the pane is requested and applied when it - // arrives, so a startup command keeps its name across the round trip. - let (mut state, _events) = state_with_event_queue(); - - state - .create_pane_with_now(Some("cargo test"), Some("tests")) - .unwrap(); - - assert_eq!(state.panes[0].title, "tests"); - } - - #[test] - fn a_pane_from_elsewhere_does_not_take_a_title_this_client_is_waiting_on() { - // The queue belongs to what this client asked for. Handing its label to - // someone else's pane would put the wrong name on both. - let (mut state, events) = state_with_event_queue(); - state - .create_pane_with(Some("cargo test"), Some("tests")) - .unwrap(); - - events.borrow_mut().push(BackendEvent::Created { - pane: 99, - rows: 24, - cols: 80, - requested: false, - title: None, - }); - state.poll(); - // Now the requested one arrives and claims the label it was given. - state.poll(); - - let theirs = state.panes.iter().find(|p| p.id == 99).expect("their pane"); - assert_ne!(theirs.title, "tests"); - let mine = state.panes.iter().find(|p| p.id != 99).expect("my pane"); - assert_eq!(mine.title, "tests"); - } -} diff --git a/src/session/catalog/views.rs b/src/session/catalog/views.rs index 9f975438..073742e7 100644 --- a/src/session/catalog/views.rs +++ b/src/session/catalog/views.rs @@ -143,6 +143,28 @@ impl Catalog { .collect() } + /// Repository identities paired with the panes their hubs currently own. + /// Entry Arcs are cloned out first so a hub lock is never taken while the + /// catalog runtime lock is held. + pub fn status_snapshot(&self) -> Vec { + let entries: Vec<_> = self + .runtime + .lock() + .expect("catalog runtime poisoned") + .entries() + .iter() + .map(Arc::clone) + .collect(); + entries + .into_iter() + .map(|entry| crate::session::RepositoryStatusSnapshot { + id: entry.id.clone(), + path: entry.path.clone(), + panes: entry.terminals.pane_ids(), + }) + .collect() + } + #[cfg(test)] pub fn len(&self) -> usize { self.runtime diff --git a/src/session/mod.rs b/src/session/mod.rs index 18ef0d50..66baecf8 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -21,4 +21,4 @@ pub use operations::{ }; #[cfg(test)] pub use state::test_status_encoder; -pub use state::{SessionOptions, SessionState, StatusEncoder}; +pub use state::{RepositoryStatusSnapshot, SessionOptions, SessionState, StatusEncoder}; diff --git a/src/session/operations.rs b/src/session/operations.rs index a6b36e8e..89b932a9 100644 --- a/src/session/operations.rs +++ b/src/session/operations.rs @@ -214,7 +214,7 @@ pub fn reorder_repos(state: &SessionState, ids: &[String]) { /// Mirror the served set into the shared workspace file so the next launch /// starts with the same projects. No-op unless the server was started with -/// `persist` (headless `serve`); alongside the TUI, the TUI owns that file. +/// `persist` (a headless daemon); alongside the TUI, the TUI owns that file. /// Only the open-repo list is rewritten. fn persist_workspace(state: &SessionState) { if !state.persist { diff --git a/src/session/state.rs b/src/session/state.rs index 3cf6be37..23dd5ff1 100644 --- a/src/session/state.rs +++ b/src/session/state.rs @@ -36,6 +36,14 @@ pub struct SessionState { pub(super) reload_lock: Mutex<()>, } +/// Transport-neutral repository facts needed for daemon introspection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepositoryStatusSnapshot { + pub id: String, + pub path: String, + pub panes: Vec, +} + impl SessionState { #[cfg(test)] pub fn new(options: SessionOptions) -> Self { @@ -70,6 +78,10 @@ impl SessionState { &self.prefs } + pub fn status_snapshot(&self) -> Vec { + self.catalog.status_snapshot() + } + pub fn shutdown(&self) { self.catalog.shutdown(); } diff --git a/src/session/terminal/hub_plugins_slots.rs b/src/session/terminal/hub_plugins_slots.rs index f6945abb..38a2763a 100644 --- a/src/session/terminal/hub_plugins_slots.rs +++ b/src/session/terminal/hub_plugins_slots.rs @@ -14,11 +14,11 @@ use std::time::{Duration, Instant}; /// /// A backstop against a plugin that died or lost interest, so it has to /// outlast every wait a plugin may legitimately be in the middle of. -/// Providers quote windows in hours *and* in days — a weekly quota is a real -/// case — so a value picked around the five-hour window would silently throw -/// the pane's identity away days before the wait paid off. Nine days clears -/// the longest window a bundled plugin will wait out -/// (`nightcrow-recovery`'s own clamp is eight days) with slack. +/// Providers may report reset windows in hours or days, so the slot must outlive +/// the longest bounded reset wait; otherwise it could discard the pane's +/// identity before the plugin's wait has paid off. Nine days clears the +/// longest window a bundled plugin will wait out (`nightcrow-recovery`'s own +/// clamp is eight days) with slack. /// /// Holding it that long is cheap on purpose: a token, a generation and a /// command string. The process, its fds and its threads were let go the diff --git a/src/session/terminal/mod.rs b/src/session/terminal/mod.rs index 6e6a751f..f7ad4753 100644 --- a/src/session/terminal/mod.rs +++ b/src/session/terminal/mod.rs @@ -1,7 +1,7 @@ //! Terminals owned by the viewer, one hub per repository. //! //! These are **not** the TUI's panes — the viewer owns its own [`PtyBackend`], -//! so `nightcrow serve` offers terminals with no TUI running at all. +//! so `nightcrow -d` offers terminals with no TUI running at all. //! //! Raw PTY bytes go to the browser untouched. The hub does parse the stream — //! through a per-pane emulator — for what it cannot get any other way: the @@ -153,6 +153,17 @@ impl TerminalHub { &self.startup } + /// Pane identities owned by this repository's hub, in canonical order. + pub(crate) fn pane_ids(&self) -> Vec { + self.state + .lock() + .expect("terminal state poisoned") + .panes + .iter() + .map(|pane| pane.id) + .collect() + } + /// How many startup terminals this hub will open. No configured commands /// means one bare shell, matching the TUI's default. fn startup_count(&self) -> usize { diff --git a/src/web/viewer/mod.rs b/src/web/viewer/mod.rs index c516763a..2461a2eb 100644 --- a/src/web/viewer/mod.rs +++ b/src/web/viewer/mod.rs @@ -1,6 +1,6 @@ //! Web viewer: a browser UI for the git panel and terminals, served as its //! own HTTP server. Nothing here touches `App`, `ui`, or `input`, which lets -//! the server run headless (`nightcrow serve`). +//! the server run headless (`nightcrow -d`). pub mod assets; pub mod clone_jobs; diff --git a/src/web/viewer/server/tests/auth.rs b/src/web/viewer/server/tests/auth.rs index 8e8706d9..bdc7aac7 100644 --- a/src/web/viewer/server/tests/auth.rs +++ b/src/web/viewer/server/tests/auth.rs @@ -14,7 +14,7 @@ fn api_requires_authentication() { #[test] fn opening_a_repository_adds_it_to_the_served_set() { - // Start empty, the way `serve` with no --repo now does, then open a + // Start empty, the way a headless daemon with no configured repository does, then open a // repository from the browser. let server = server(&[]); let token = login(server.addr()); @@ -39,7 +39,6 @@ fn opening_a_repository_requires_authentication() { let server = server(&[]); let (dir, path) = make_repo(); let body = format!("{{\"path\":{}}}", serde_json::to_string(&path).unwrap()); - let response = post(server.addr(), "/api/repos", &body, None); assert!(response.starts_with("HTTP/1.1 401"), "got: {response}"); @@ -144,7 +143,6 @@ fn auth_is_checked_before_the_repository_is_looked_up() { let known = get(server.addr(), &format!("/api/status?repo={real}"), None); let unknown = get(server.addr(), "/api/status?repo=r9999", None); - assert!(known.starts_with("HTTP/1.1 401"), "got: {known}"); assert!(unknown.starts_with("HTTP/1.1 401"), "got: {unknown}"); drop(dir); @@ -198,7 +196,6 @@ fn the_login_cookie_lasts_as_long_as_the_configured_session() { "the cookie must expire with the session: {response}" ); } - #[test] fn logout_revokes_the_session_server_side() { // Clearing the cookie is not enough: cookies are not port-isolated, so @@ -217,7 +214,6 @@ fn logout_revokes_the_session_server_side() { ); drop(dir); } - #[test] fn a_framed_logout_is_refused_and_leaves_the_session_alone() { // The HTML preview's sandboxed frame could navigate itself to /logout and diff --git a/viewer-ui/AGENTS.md b/viewer-ui/AGENTS.md index de812609..76b2b4f8 100644 --- a/viewer-ui/AGENTS.md +++ b/viewer-ui/AGENTS.md @@ -1,12 +1,12 @@ # viewer-ui scope -저장소 공통 작업 흐름은 [루트 AGENTS.md](../AGENTS.md)를 따른다. 파일 크기와 플랫폼 규칙은 [guardrails](../.agents/rules/guardrails.md), 테스트 배치와 계약 검증 규칙은 [testing](../.agents/rules/testing.md)가 정본이므로 이 문서에서 반복하지 않는다. +저장소 공통 작업 흐름은 [루트 AGENTS.md](../AGENTS.md)를 따른다. 이 문서는 viewer와 서버 사이의 계약, 번들, 개발 서버처럼 이 디렉터리에서만 놓치기 쉬운 경계만 다룬다. ## 프론트엔드 계약 - `viewer-ui/src/api.ts`와 `viewer-ui/src/api/`의 HTTP, SSE, WebSocket 타입·인코더·디코더는 `src/web/viewer/dto/`와 서버 terminal protocol의 반대편이다. 필드, enum variant, 메시지 순서 또는 경로를 바꾸면 양쪽 구현과 해당 contract/integration test를 함께 갱신한다. - `api.fixture.json`은 Rust DTO에서 생성되는 커밋 대상 wire fixture다. Rust payload가 바뀌면 저장소 루트에서 `UPDATE_API_FIXTURE=1 cargo test the_wire_fixture`로 재생성하고, fixture diff를 검토한 뒤 TypeScript API 타입을 맞춘다. fixture를 임의로 손으로 고쳐 계약 drift를 숨기지 않는다. -- API 계약 변경은 `npm --prefix viewer-ui test`와 `npm --prefix viewer-ui run build`로 확인한다. `api.contract.test.ts`의 타입 대입은 누락·이름 변경·타입 변경을 잡고, Rust fixture test는 서버가 추가하거나 제거한 payload를 고정한다. +- API 계약 변경은 [공통 Verify 절차](../docs/getting-started.md#building-and-testing)의 frontend 게이트로 확인한다. `api.contract.test.ts`의 타입 대입은 누락·이름 변경·타입 변경을 잡고, Rust fixture test는 서버가 추가하거나 제거한 payload를 고정한다. ## 번들 및 개발 서버 From 95e39e86bdfa8075b6beca604252dc61322f059f Mon Sep 17 00:00:00 2001 From: whackur Date: Sat, 29 Aug 2026 14:41:26 +0900 Subject: [PATCH 9/9] fix(daemon): handle macOS socket error semantics --- src/daemon/client.rs | 9 +-------- src/daemon/client_tests.rs | 20 ++++++++++++++------ src/daemon/serve_tests/session.rs | 8 ++++---- src/daemon/transport.rs | 18 ++++++++++++++++++ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/daemon/client.rs b/src/daemon/client.rs index 5e45624f..b1cac99a 100644 --- a/src/daemon/client.rs +++ b/src/daemon/client.rs @@ -65,7 +65,7 @@ impl DaemonClient { /// The former may start a background daemon; the latter must be reported. pub(crate) fn connect_for_attach(path: &Path) -> std::result::Result { let stream = UnixStream::connect(path).map_err(|err| { - let unavailable = is_unavailable_socket_error(&err); + let unavailable = super::transport::is_unavailable(&err); let context = if unavailable { format!( "no nightcrow daemon on {} — start one with `nightcrow -d`", @@ -246,13 +246,6 @@ impl DaemonClient { } } -fn is_unavailable_socket_error(error: &std::io::Error) -> bool { - matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused - ) -} - #[cfg(test)] #[path = "client_tests.rs"] mod tests; diff --git a/src/daemon/client_tests.rs b/src/daemon/client_tests.rs index 8639d298..9ad4588f 100644 --- a/src/daemon/client_tests.rs +++ b/src/daemon/client_tests.rs @@ -2,7 +2,7 @@ use super::DaemonClient; use crate::daemon::frame::{Frame, read_frame, write_frame}; use crate::daemon::protocol::{ServerMessage, version}; use crate::daemon::socket::DaemonSocket; -use crate::daemon::transport::UnixListener; +use crate::daemon::transport::{UnixListener, is_unavailable}; use std::io::Write; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -93,21 +93,29 @@ fn attaching_where_no_daemon_listens_says_so() { } #[test] -fn only_missing_or_refused_sockets_are_attach_startup_failures() { - assert!(super::is_unavailable_socket_error(&std::io::Error::from( +fn only_absent_endpoint_errors_are_attach_startup_failures() { + assert!(is_unavailable(&std::io::Error::from( std::io::ErrorKind::NotFound, ))); - assert!(super::is_unavailable_socket_error(&std::io::Error::from( + assert!(is_unavailable(&std::io::Error::from( std::io::ErrorKind::ConnectionRefused, ))); - assert!(!super::is_unavailable_socket_error(&std::io::Error::from( + assert!(!is_unavailable(&std::io::Error::from( std::io::ErrorKind::PermissionDenied, ))); - assert!(!super::is_unavailable_socket_error(&std::io::Error::from( + assert!(!is_unavailable(&std::io::Error::from( std::io::ErrorKind::InvalidInput, ))); } +#[cfg(unix)] +#[test] +fn a_non_socket_endpoint_is_an_attach_startup_failure() { + let error = std::io::Error::from_raw_os_error(libc::ENOTSOCK); + + assert!(is_unavailable(&error)); +} + #[test] fn opening_a_repository_comes_back_as_a_broadcast() { let (repo, path) = crate::test_util::make_repo(); diff --git a/src/daemon/serve_tests/session.rs b/src/daemon/serve_tests/session.rs index d382af9c..c93b3aaf 100644 --- a/src/daemon/serve_tests/session.rs +++ b/src/daemon/serve_tests/session.rs @@ -39,6 +39,10 @@ fn a_version_mismatch_is_reported_rather_than_ignored() { let daemon = daemon(&dir, &[]); let before_repos = daemon.state().status_snapshot(); let mut client = Client::attach_raw(daemon.path()); + client + .stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .expect("sets a timeout"); let answer = client.ask(ClientMessage::Hello { version: "0.0.1-from-another-build".into(), @@ -51,10 +55,6 @@ fn a_version_mismatch_is_reported_rather_than_ignored() { } other => panic!("expected a mismatch report, got {other:?}"), } - client - .stream - .set_read_timeout(Some(std::time::Duration::from_secs(1))) - .expect("sets a timeout"); assert!( read_frame(&mut client.stream) .expect("the mismatched connection closes") diff --git a/src/daemon/transport.rs b/src/daemon/transport.rs index 5a564baa..7619470d 100644 --- a/src/daemon/transport.rs +++ b/src/daemon/transport.rs @@ -12,3 +12,21 @@ pub(crate) use std::os::unix::net::{UnixListener, UnixStream}; #[cfg(windows)] pub(crate) use uds_windows::{UnixListener, UnixStream}; + +/// Whether connecting failed because no daemon can be listening at the path. +pub(crate) fn is_unavailable(error: &std::io::Error) -> bool { + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused + ) { + return true; + } + // macOS reports ENOTSOCK when a stale socket path has been replaced by a + // regular file. It means the endpoint is just as unavailable as a refused + // connection; the next daemon can remove it after taking the instance lock. + #[cfg(unix)] + if error.raw_os_error() == Some(libc::ENOTSOCK) { + return true; + } + false +}