Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 5 additions & 4 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
#
# * no Rust and no manifest touched -> every cargo invocation is skipped, so a
# docs-only push costs nothing
# * only `plugins/` touched -> just that crate is checked. Sound because
# `nightcrow-recovery` does not depend on `nightcrow`: it speaks the plugin
# protocol over a pipe and shares no code (see its Cargo.toml)
# * only `plugins/` touched -> just those crates are checked. Sound
# because neither depends on `nightcrow`: `nightcrow-recovery` speaks the
# plugin protocol over a pipe and `nightcrow-memory` reads only the pane
# environment, so they share no code with it (see their Cargo.toml)
# * only the host touched -> just `nightcrow`
# * a manifest or lockfile touched -> the whole workspace, since a dependency
# change can reach either crate
Expand Down Expand Up @@ -178,7 +179,7 @@ else
if [ -n "$manifest" ] || { [ -n "$host" ] && [ -n "$plugin" ]; }; then
scope=(--workspace)
elif [ -n "$plugin" ]; then
scope=(-p nightcrow-recovery)
scope=(-p nightcrow-recovery -p nightcrow-memory)
else
scope=(-p nightcrow)
fi
Expand Down
1 change: 1 addition & 0 deletions .github/release-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
{ "kind": "cargo-workspace", "path": "Cargo.toml" },
{ "kind": "cargo-lock", "path": "Cargo.lock", "package": "nightcrow" },
{ "kind": "cargo-lock", "path": "Cargo.lock", "package": "nightcrow-recovery" },
{ "kind": "cargo-lock", "path": "Cargo.lock", "package": "nightcrow-memory" },
{ "kind": "npm-package", "path": "viewer-ui/package.json" },
{ "kind": "npm-lock", "path": "viewer-ui/package-lock.json" }
],
Expand Down
84 changes: 82 additions & 2 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
# that speak the plugin protocol over a pipe, never link against nightcrow, and
# are excluded from the published crate.
[workspace]
members = [".", "plugins/nightcrow-recovery"]
members = [".", "plugins/nightcrow-recovery", "plugins/nightcrow-memory"]

# The one place the application version is written. Every crate in the
# workspace inherits it, so the plugin cannot be released at a version the host
# was never built at — a class of drift the release check could only report
# after the fact.
[workspace.package]
version = "0.1.15"
version = "0.1.16"

[package]
name = "nightcrow"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The daemon socket is a Unix-domain socket on every platform: a filesystem path o
- 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).
- Optional external plugins, including bundled recovery that waits out Codex/OpenCode usage limits and reopens exact sessions, and a shared memory the agents in one project's panes read and write over MCP → [Plugins](docs/plugins.md).

## Security

Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/plugin-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ relaunch는 같은 `PaneId`를 부활시키지 않고 새 id와 증가한 genera

`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 <SESSION_ID>`를 제안한다. OpenCode는 `/session/status`를 관찰하고 retry 중에는 개입하지 않으며, live process가 `idle`이 되면 `NeedsAttention`을 보고하고 process가 끝난 뒤에만 `--session <SESSION_ID>` relaunch를 제안한다. provider 한도를 우회하지 않으며 transcript나 원본 payload를 host 계약 밖으로 보내지 않는다.

## Pane helper: shared memory

`plugins/nightcrow-memory`는 host가 실행하는 plugin child가 아니라 pane 안에서 provider CLI가 띄우는 helper다. plugin protocol을 쓰지 않고 `[[plugin]]`에도 선언하지 않으며, host와의 계약은 pane spawn이 주입하는 `NIGHTCROW_PLUGIN_RUNTIME_DIR`의 마지막 경로 요소(hub key)와 `NIGHTCROW_PANE_TOKEN` 두 환경 변수뿐이다. 따라서 guard·watcher·generation 어느 것도 거치지 않고, pane을 읽거나 입력하는 경로도 없다.

각 helper는 stdio MCP 서버로 동작하며 hub key로 정한 SQLite 파일 하나를 직접 연다. 사이에 서버 프로세스를 두지 않은 것은 SQLite가 이미 프로세스 간 writer를 직렬화하기 때문이다. WAL과 busy timeout이 동시성의 전부이고, journal mode 전환만은 SQLite가 busy handler 없이 즉시 `BUSY`를 돌려주므로 열 때 직접 재시도한다. 저장 위치는 repository 밖(`~/.nightcrow/memory`)이며 hub key 형식을 검증한 뒤에만 파일 이름으로 쓴다.

token은 여기서도 인증 수단이 아니다. 작성자 label에는 앞 6자만 쓰고 전체 값은 저장하지 않는다. 읽은 항목은 다른 agent가 쓴 미검증 텍스트이므로 모든 읽기 결과에 그 사실을 알리는 문구를 붙이지만, agent 간 prompt injection 전파를 구조적으로 막지는 못한다.

## Config reload

`[[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마다 재생성하지 않는다.
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ terminal connection은 읽기와 쓰기를 bounded polling으로 다루며, stal

사용자의 key, paste, pointer down/click, wheel 입력은 session size-owner claim으로 번역한다. 같은 owner의 활동은 이미 owner인 viewer connection에 확인 응답을 다시 보내어 수동 refit에 쓰며, 일반 활동으로 owner 전환이나 강제 resize를 일으키지는 않는다. browser resize·focus·reconnect는 소유권을 바꾸지 않는다.

서버가 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에 적용한다. soft keyboard로 visual viewport가 줄어든 동안은 geometry로 취급하지 않는다: fit도 resize도 보내지 않고 pane body가 terminal의 아래쪽을 보이도록 잘라내며, 키보드가 닫힌 뒤의 layout을 한 번 적용한다. keyboard 판정은 layout viewport와 visual viewport의 높이 차이 하나로 한다.
서버가 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에 적용한다. soft keyboard로 visual viewport가 줄어든 동안은 geometry로 취급하지 않는다: fit도 resize도 보내지 않고 pane body가 terminal을 잘라내되 커서가 있는 줄이 보이는 가장 아래쪽 창을 보이며(가득 찬 화면은 아래쪽, 막 연 터미널은 프롬프트가 있는 위쪽), 키보드가 닫힌 뒤의 layout을 한 번 적용한다. keyboard 판정은 layout viewport와 visual viewport의 높이 차이 하나로 한다.

## Wire and log contract

Expand All @@ -49,11 +49,11 @@ request body 상한은 route마다 다르다. 대부분은 작은 control payloa

`/api/preview/edit`는 인라인 편집용 프리뷰를 조립한다. `srcdoc`는 부모 CSP를 상속해 인라인 스크립트가 안 도므로, 편집 에이전트가 실행되려면 자체 정책을 실은 네트워크 응답이어야 한다. 블록 검출(parse5)은 클라이언트에 있고 조립된 HTML은 64KB 본문 상한을 넘으므로, 클라이언트는 작은 insert 목록(블록마다 마커 하나 + 에이전트를 담은 head 페이로드)을 UTF-8 바이트 오프셋으로 POST한다. 서버는 파일을 다시 읽어 `base_hash`(blob oid)와 일치할 때만(불일치 시 `409` + `currentHash`) insert를 splice하고, 결과를 1회용 토큰으로 stash한 뒤 토큰을 돌려준다. 프레임은 `GET /api/preview/edit?token=`으로 그 문서를 한 번 받아 preview 정책(sandbox+실행 CSP) 아래 로드한다. stash는 세션 트러스트 안의 임시 상태이고 토큰은 소비되며, 조립물은 디스크에 쓰이지 않는다. 편집 결과 저장은 `POST /api/file`이 맡는다.

`/api/log`는 `MAX_LOG_PAGE = 100`과 `from=<head oid> + 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으로 갱신한다.
`/api/log`는 `MAX_LOG_PAGE = 100`과 `from=<head oid> + 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으로 갱신한다. ref chip과 upstream 대비 ahead/behind는 page에 싣지 않고 `/api/log/decorations`가 저장소 전체를 한 번에 준다. page가 묘사한 history는 변하지 않지만 장식은 ref가 움직일 때마다 바뀌므로 — push·fetch, 같은 commit에서의 branch 전환 — client는 status의 HEAD·branch·`refs` digest·upstream 이름 중 하나가 바뀌면 장식을 통째로 다시 받는다. 응답은 ref label `MAX_LOG_DECORATION_REFS = 2000`개로 자르며, 원격 branch·tag부터 버리고 HEAD branch는 남긴 채 `truncated`로 알린다. 실패한 요청은 5초 뒤 다시 시도해 변화가 버려지지 않는다.

## Browser state and frontend

`viewer.json`에 session accent·`upper_pct`와 project별 last view/maximize를 저장한다. sidebar width는 픽셀값이라 화면 하나의 사실이므로 서버에 두지 않고 브라우저 `localStorage`가 기기별로 가진다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다.
`viewer.json`에 session accent·`upper_pct`와 project별 last view/maximize를 저장한다. sidebar width는 픽셀값이라 화면 하나의 사실이므로 서버에 두지 않고 브라우저 `localStorage`가 기기별로 가진다. 좁은 화면의 Repo/Content/Terminal 선택도 브라우저 `localStorage`에 저장해 새로고침 뒤 복원하며, 공유 저장소 view 상태에는 넣지 않는다. active repo는 absolute worktree path로 저장하고 응답에서 opaque id로 변환한다. 값은 서버·client 양쪽에서 clamp하며 view path/oid/tab/face는 저장·복원 경계에서 sanitize한다. TUI의 `workspace.json`과 viewer preference는 별도 소유다.

키보드는 `document` capture 단계의 결정점 하나만 둔다. 두 listener는 키가 소비되었는지에 합의할 수 없어, 지는 쪽이 pane에 필요한 키를 먹거나 앱 명령을 escape sequence로 흘린다. 명령은 물리 키가 아니라 semantic action id로 registry에 두고 keyboard·help·버튼이 같은 표를 읽는다. TUI의 Rust key table을 복제하지 않고, 브라우저가 의미를 유지·재해석·미지원하는지를 registry가 기록한다. terminal panel의 명령은 page 아래에 있으므로 panel이 intent bus에 등록하며, 그 등록 여부가 availability의 단일 근거다. TUI hint bar에 대응하는 web hint line은 같은 registry와 availability에서 순수 함수로 만들어지며, leader의 armed 상태는 keystroke가 읽는 ref의 mirror로만 렌더한다. leader 선호는 client-local per-browser 값이므로 `viewer.json`이나 session이 아니라 browser storage에 둔다. 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`도 동작해야 한다.

Expand Down
6 changes: 6 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ HTTP client는 명령의 동기 실행 모델과 기존 async-runtime 배제 결

Rust에는 안정적인 plugin ABI가 없어 dylib가 compiler/runtime 결합과 주소 공간의 안전성 문제를 만든다. plugin을 child process로 분리하고 versioned NDJSON으로 통신하면 host가 line/payload bound를 적용하고 plugin crash를 pane에 전파하지 않을 수 있다.

### 공유 메모리는 서버 없이 SQLite 파일을 직접 연다

pane의 agent들이 메모를 공유하는 `nightcrow-memory`는 host가 띄우는 plugin 프로세스와 소켓을 두는 구조 대신, 각 agent의 MCP helper가 프로젝트별 SQLite 파일을 직접 여는 구조를 택했다. host는 pane이 지목하거나 `watch_on_signal`을 켠 plugin만 실행하므로 pane을 보지 않는 프로세스를 띄우려면 그 스위치를 본뜻과 다르게 써야 했고, 단일 writer 프로세스가 주는 것은 SQLite가 이미 제공하는 직렬화뿐이었다. 서버가 없으면 Windows용 소켓 전송 계층과 프로세스 수명 관리도 필요 없다. 다른 pane에 입력을 밀어 넣는 기능이 필요해지면 그때 host plugin을 더하되 저장 형식과 도구는 그대로 둔다.

저장소는 `rusqlite`(MIT, `bundled`)다. 여러 프로세스의 동시 쓰기와 전문 검색(FTS5)이 필요한데, 파일(JSONL·Markdown)은 잠금과 색인을 직접 만들어야 하고 `sled`·`redb` 같은 embedded KV는 한 프로세스만 파일을 열 수 있어 프로세스마다 helper가 뜨는 구조에 맞지 않는다. `bundled`는 C compiler를 요구하지만 세 플랫폼에 같은 SQLite와 FTS5를 보장한다. MCP는 SDK 없이 구현했다. 쓰는 표면이 stdio JSON-RPC의 네 method뿐이고, 공식 Rust SDK는 async runtime을 끌어온다.

### 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 기준으로 센다.
Expand Down
Loading
Loading