Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Cargo.lock

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

5 changes: 2 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ portable-pty = "0.9"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3"
toml = "1"
toml_edit = "0.25"
dirs = "6"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Expand Down Expand Up @@ -82,6 +84,3 @@ ctrlc = "3"
# `GetLocalTime` for the wall clock. Already in the lockfile transitively;
# adding it directly here so the feature gate is explicit.
windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation"] }

[dev-dependencies]
tempfile = "3"
3 changes: 2 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ live_watch = true # watch expanded dirs and refresh the tree live; set f
# 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"]
# # empty by default, which refuses relaunches.
# # empty by default, which refuses relaunch
# # arguments. Entries are flags/subcommands.
# # These three are what the bundled recovery
# # plugin needs for Claude/Codex/OpenCode.
#
Expand Down
56 changes: 34 additions & 22 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ OSC 0/2 탭 타이틀 캡처, 마우스 캡처(클릭 포커스/포워딩, 휠
```
src/
├── main.rs # entry point: dispatch to daemon / attach / serve / init
├── cli.rs, cli/ # Cli/Commands, run_daemon/run_init, `nightcrow plugin`
├── 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)
Expand All @@ -90,8 +91,8 @@ src/
│ # logging.rs (file logger, rotation + retention), paths.rs
│ # (tilde expansion), signals.rs (SIGINT/SIGTERM shutdown),
│ # threading.rs (try_timed_join)
├── app.rs, app/ # App struct + per-feature impls: auto_follow, commit-log
│ # fetch/pagination/apply, diff & file-view loaders, focus,
├── app.rs, app/ # App aggregate + InteractionState; per-feature impls: auto_follow,
│ # commit-log fetch/pagination/apply, diff & file-view loaders, focus,
│ # navigation, log_nav, scroll, session_io, snapshot_io,
│ # terminal_ctrl, tree, tree_nav
├── config.rs, config/ # config.toml root + layout/theme/input, log, panels,
Expand Down Expand Up @@ -140,7 +141,8 @@ src/
│ ├── 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 # ~/.nightcrow/plugins: install / list / remove
│ └── registry.rs, registry/ # ~/.nightcrow/plugins: config snippets, executable
│ # resolution, atomic install/list/remove storage
├── git/
│ ├── diff.rs, diff/ # types, snapshot loader, diff/commit loaders, commit_log, refs
│ ├── clone.rs, clone/ # delegate `git clone` to the binary; URL scheme whitelist
Expand All @@ -149,22 +151,22 @@ src/
├── 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)
└── web/ # browser surface
├── common/ # server-agnostic primitives (no git or terminals): auth.rs
│ # (Argon2 verify, session tokens, login rate limit), http.rs,
│ # sse.rs (SseStream), conn.rs (ConnectionSlot accounting)
├── session/ # daemon-owned, transport-neutral shared session core
│ ├── state.rs, operations.rs, reload.rs # ownership, mutations, live config reload
│ ├── catalog/ # opaque repo ids, atomic swap, 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 # ceilings: log page, tree entries, diff bytes/lines, PTYs
├── dto/ # whitelisted wire types + PROTOCOL_VERSION envelope
├── catalog/ # opaque repo ids, atomic swap, ordering, config tables
├── runtime/ # per-repo thread: SnapshotChannel drain + conflated SSE fan-out
├── terminal/ # per-repo TerminalHub owning its own PtyBackend
├── session.rs, reload.rs, size_owner.rs # transport-independent session ops;
│ # live config.toml re-read; which screen the PTYs are fitted to
├── prefs/ # ~/.nightcrow/viewer.json: accent, widths, active repo, maximized
├── clone_jobs.rs, clone_jobs/ # in-flight clone tracking (one at a time)
├── highlight.rs, assets.rs # syntect spans for payloads; rust-embed of dist + CSP
└── server/ # HTTP routes, dispatch, mutations, SSE, /ws/term
├── 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
```

## Stack
Expand All @@ -178,14 +180,23 @@ src/
| 터미널 에뮬레이션 | alacritty_terminal 0.26 |
| 파일시스템 감시 | notify 8.2 + notify-debouncer-mini 0.7 |
| 파일 로깅 | tracing + tracing-subscriber + tracing-appender |
| 설정 파싱 | toml 0.9 + serde |
| 세션 저장 | serde_json |
| 설정 파싱 | 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 |

`toml_edit`는 생성한 웹 비밀번호만 바꾸면서 기존 TOML의 주석·공백·키 순서를 보존하려고 쓴다.
`toml`로 전체 문서를 다시 직렬화하거나 문자열을 직접 고치는 대안은 서식을 잃거나 동등한 table 표현을
빠뜨릴 수 있어 채택하지 않았다. `tempfile`은 상태와 설정을 대상 디렉터리의 충돌 방지 임시 파일에 쓴 뒤
`persist`로 교체하는 데 쓴다. 같은 디렉터리를 쓰면 부분 기록과 파일시스템 간 이동 위험을 줄이지만,
교체의 원자성은 파일시스템과 플랫폼에 달려 있다. `std::fs`만 쓰는 대안은 고유 이름의 배타적 생성,
실패 시 정리, Windows 교체 동작을 직접 구현해야 하므로 채택하지 않았다.

PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다. 초기에는 tmux control-mode
백엔드(`TmuxBackend`)도 병행 지원했으나, 중첩 TUI 키보드 라우팅 문제를 leader(prefix) 모델로
해결하면서 tmux 의존성 없이 `PtyBackend`만으로 충분해져 제거했다.
Expand Down Expand Up @@ -218,7 +229,8 @@ PTY 관리는 portable-pty 기반 `PtyBackend` 단일 구현으로 정리됐다.

## Future Refactor Notes

- `App`은 도메인별 sub-struct(`StatusView`, `LogView`, `DiffPane`, `TerminalState`, `RepoInput`)와
- `App`은 도메인별 sub-struct(`StatusView`, `LogView`, `DiffPane`, `TerminalState`,
`InteractionState`, `RepoInput`)와
`app/` 서브모듈로 impl 책임이 나뉘어 있지만, 여전히 한 구조체가 모든 sub-state를 들고 있다. 추가
분리가 필요해지면 sub-struct별 명시적 manager로 승격하는 게 다음 단계다.
- 대형 diff에서 j/k 빠른 탐색 시 동기 diff 로드가 여전히 ms 단위 블로킹을 만들 수 있다. Repository
Expand Down
9 changes: 5 additions & 4 deletions docs/architecture/plugin-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ relaunch하는가, 되돌릴 명령이 있는가, 제어문자가 섞이지 않
- **프로세스 해제와 slot 폐기를 분리한다**: 한도 대기는 몇 시간일 수 있다. 죽은 자식의 fd와 스레드를
그 시간 내내 붙잡는 것은 낭비이므로 `release_process`는 PTY를 놓고 slot만 남긴다. 아무도
relaunch하지 않으면 `PENDING_RELAUNCH_TTL`에 slot을 폐기한다.
- **권한 인자는 사용자가 선언한다**: relaunch가 덧붙일 수 있는 플래그는 `[[plugin]]`의
`allowed_resume_flags`뿐이고 기본은 빈 목록이다. 코어가 특정 CLI의 위험 플래그 이름을 하드코딩하는
대안은 곧 코어가 provider를 아는 것이라 택하지 않았다. 인자는 셸 메타문자를 거부한 뒤 개별로
quote되며, 원래 명령 문자열은 수정되지 않는다(다음 relaunch가 인자를 누적하지 않도록).
- **권한 인자는 사용자가 선언한다**: relaunch의 첫 토큰(플래그 또는 subcommand)과 `-`/`/`로
시작하는 option 토큰은 `[[plugin]].allowed_resume_flags`에 있어야 하며 기본은 빈 목록이다. 코어가
특정 CLI의 위험 플래그 이름을 하드코딩하면 provider 경계를 깨므로 택하지 않았다. 허용 문자는 POSIX
shell과 `cmd.exe`에서 그대로 전달되는 안전한 공통 집합으로 제한하며 별도 quote 문자를 넣지 않는다.
원래 명령 문자열은 수정하지 않아 다음 relaunch에 인자가 누적되지 않는다.
- **와이어 계약이 두 벌 있다**: plugin은 독립 빌드라 `plugins/nightcrow-recovery`가 프로토콜 타입을
따로 갖는다. `PROTOCOL_VERSION`을 진짜 주장으로 만들려면 그래야 하고, 양쪽 모두 JSON 모양을
리터럴로 고정한 테스트가 있어 드리프트는 테스트 실패로 나타난다.
Expand Down
12 changes: 6 additions & 6 deletions docs/architecture/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ trait TerminalBackend {
편의보다 무겁기 때문이다 — TUI와 브라우저를 나란히 두면 같은 세션이 두 색으로 보였고, 어느
쪽이 이 세션의 색이냐는 물음에 답할 수 있는 값이 아예 없었다. 저장소별 색이 대신하던 "지금 어느
프로젝트인가"는 탭 이름과 활성 탭 강조가 이미 답한다. 값은 `viewer.json` 하나에 살고
(`web/viewer/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은
(`session/prefs`), 어느 표면에서 바꾸든 세션 전체가 따라온다 — 대신 프로젝트를 바꿔도 색은
그대로다. `[theme] name`은 아직 한 번도 색을 고르지 않은 세션의 시작색으로 남는다.

### 데몬이 세션을 감시한다 (`daemon/watch.rs`)
Expand Down Expand Up @@ -100,7 +100,7 @@ alternate screen을 쓰는 풀스크린 TUI를 나중에 다시 흘릴 방법은
`claim_size`로 명시적 탈취(TUI `<prefix> z`, 뷰어의 "fit to this screen" 버튼), 소유자가 떠나면
남은 중 가장 최근에게, 아무도 없으면 마지막 크기 유지.

- **소유권은 hub별이 아니라 세션 하나가 갖는다**(`web/viewer/size_owner.rs`). 어느 repo가 앞에
- **소유권은 hub별이 아니라 세션 하나가 갖는다**(`session/size_owner.rs`). 어느 repo가 앞에
있는지는 세션 공유라 "이 세션은 어느 화면에 맞춰져 있나"는 질문이 하나다. hub마다 따로
답하던 때는 탭을 옮길 때마다 붙어 있는 모든 페이지가 동시에 재접속해 소유권이 **핸드셰이크가
늦게 끝난 쪽**으로 갔다.
Expand Down Expand Up @@ -179,7 +179,7 @@ awake를 한 번 더 보고 잠들었으면 결과를 넘기지 않는다. 첫
지점을 테스트로 고정해 두고 상한은 바꾸지 않았다 — 거기 닿는 출력은 대부분 텍스트가 아니라
repaint 시퀀스이고, 상한은 저장소×pane마다 지불된다.

**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`web/viewer/terminal/hub_modes.rs`,
**붙는 클라이언트에게는 기록이 아니라 상태를 준다**(`session/terminal/hub_modes.rs`,
`hub_repaint.rs`, `runtime/emulator/modes.rs`). 바이트 링은 역사이지 스냅샷이 아니어서, 프로그램이
시작할 때 한 번 켜고 다시 말하지 않는 것들(alternate screen, 마우스 리포팅, bracketed paste,
DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이언트는 **프로그램이 설정한 적 없는
Expand All @@ -201,7 +201,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이
값만 전달한다") 그 repaint가 사라졌다. 사용자가 깨진 화면에 누르는 복구 키가 `Ctrl+L`이고
fullscreen Claude Code는 그것을 2초 안에 두 번 받으면 `/clear`를 실행한다 — 대화가 연달아 지워졌다.

**입력의 출처를 기록한다**(`web/viewer/terminal/hub_diag.rs`, `session.rs`,
**입력의 출처를 기록한다**(`session/terminal/hub_diag.rs`, `session.rs`,
`viewer-ui/src/lib/clearKeyProbe.ts`). 특정 사건 때문에 존재하는 계측이다 — 5초 사이에 대화가
14번 지워졌는데 `0x0c`가 30번쯤 기계적 간격으로 들어왔다는 뜻이고, **무엇이 보냈는지 알 수
없었다**. nightcrow가 합성하는 입력은 스크롤·마우스 리포트와 plugin의 `continue`뿐이고 후자는 그
Expand All @@ -218,7 +218,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이
말이므로 분당 상한을 두고, `code`는 ASCII 영숫자 16자로 깎는다(줄바꿈이 들어오면 로그 한 줄을
위조할 수 있다). 원인이 특정되면 이 계측은 지운다.

## Config Reload (`web/viewer/reload.rs`)
## Config Reload (`session/reload.rs`)

`config.toml`을 고칠 때마다 데몬을 내렸다 올리면 살아 있는 pane이 전부 죽는다 — agent CLI가
작업 중이던 것까지. 그래서 **두 테이블만 다시 읽는다.** 무엇이 즉시 닿고 무엇이 안 닿는지는
Expand Down Expand Up @@ -271,7 +271,7 @@ DECCKM)은 하루 지난 pane에서 이미 밀려나 있다. 그러면 클라이
때문이다 — `nightcrow-recovery`의 `panes: HashMap`은 메모리뿐이다. host가 대신 경고할 수 없다:
**살아 있는** pane에 대한 대기는 plugin 안에만 있고 host의 `pending`에는 없다. 그래서 이 손실의
범위를 좁히는 것이 `spec_changed`의 진짜 값이다.
- **동시 reload는 직렬화한다**(`ViewerState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의
- **동시 reload는 직렬화한다**(`SessionState::reload_lock`). 두 클라이언트가 동시에 누르면 세션의
저장소들이 서로 다른 파일을 전달받은 상태로 남을 수 있다.
- **reload와 프로젝트 열기의 경합은 Catalog의 mutation lock이 막는다.** 테이블 교체와 "알려줄
저장소 목록" 스냅샷을 **같은 락 안에서** 처리하고 그 목록을 호출자에게 돌려준다
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
가로채이지 않고 PTY로 통과해야 하므로, 앱 전역 명령은 leader 뒤에 한 키를 눌러야만 실행된다.

- **Leader (prefix)**: 기본값 `Ctrl+F`, `[input] leader`로 변경 가능(`config.rs::parse_leader`가
`ctrl+<letter>`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면 `App.prefix_armed`가
켜지고 다음 키 한 개가 앱 명령(`input::prefix_action`)으로 해석된다. **타임아웃은 없다** — 해제
`ctrl+<letter>`만 허용하고 예약키·인코딩 불가 chord는 거부). leader를 누르면
`App.interaction.prefix_armed`가 켜지고 다음 키 한 개가 앱 명령(`input::prefix_action`)으로
해석된다. **타임아웃은 없다** — 해제
경로는 셋뿐이다: 매핑된 키 → Action 실행 후 해제, 미매핑 키 → 소비 후 해제, `Esc`/`Ctrl+C` →
취소. `<L> <L>`는 terminal focus에서 leader를 `encode_key`로 리터럴 PTY 전송한다.
- **prefix 매핑**: `t`=NewPane, `w`=ClosePane(terminal focus 한정 — unfocus 시 active pane이 다른
Expand Down
Loading