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
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
80 changes: 80 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# 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
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: 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
66 changes: 66 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,72 @@ nightcrow plugin install target/release/nightcrow-recovery --name 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 <SESSION_ID>` 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 <SESSION_ID>`.

## Bundled `nightcrow-memory`

`nightcrow-memory` gives the coding agents running in one project's panes a shared set of notes and a status board, as tools they call over the [Model Context Protocol](https://modelcontextprotocol.io). An agent in one pane saves a fact; an agent in another pane of the same project can search for it, including after both have exited.

It is not declared under `[[plugin]]` and nightcrow does not start it. Each agent's CLI runs it as a local MCP server, and it finds its project through two variables nightcrow sets on every pane: `NIGHTCROW_PLUGIN_RUNTIME_DIR`, whose last component is the per-project key, and `NIGHTCROW_PANE_TOKEN`. Started anywhere else, the server still answers and every tool call fails with the reason. It never reads or types into a pane.

Build and install it from a checkout:

```bash
cargo build --release -p nightcrow-memory
nightcrow plugin install target/release/nightcrow-memory --name memory
```

Ignore the `[[plugin]]` snippet the install prints. Register the installed executable with each agent instead, with `mcp` as its only argument:

```bash
# Claude Code
claude mcp add --scope user nightcrow-memory -- ~/.nightcrow/plugins/memory mcp
```

```toml
# Codex CLI — ~/.codex/config.toml. Codex gives an MCP server a limited
# environment, so the two pane variables have to be forwarded by name.
[mcp_servers.nightcrow-memory]
command = "/home/you/.nightcrow/plugins/memory"
args = ["mcp"]
env_vars = ["NIGHTCROW_PLUGIN_RUNTIME_DIR", "NIGHTCROW_PANE_TOKEN"]
```

```json
// OpenCode — opencode.json
{
"mcp": {
"nightcrow-memory": {
"type": "local",
"command": ["/home/you/.nightcrow/plugins/memory", "mcp"],
"environment": {
"NIGHTCROW_PLUGIN_RUNTIME_DIR": "{env:NIGHTCROW_PLUGIN_RUNTIME_DIR}",
"NIGHTCROW_PANE_TOKEN": "{env:NIGHTCROW_PANE_TOKEN}"
}
}
}
}
```

The server has been exercised with the official MCP client SDK; the Codex and OpenCode entries follow those tools' documentation and have not been run against them here.

| Tool | Effect |
| --- | --- |
| `memory_write` | Save one note, with optional tags. |
| `memory_search` | Notes matching any word of the query, best match first. A word also matches with an ending attached. |
| `memory_recent` | The newest notes. |
| `memory_delete` | Remove a note by the id shown beside it. |
| `board_post` | Say what this agent is working on. Replaces its previous post and expires (4 hours by default, 24 at most). |
| `board_read` | What every agent last posted and has not yet expired. |

Every entry is labelled with the client's name and the first six characters of its pane token, such as `claude-code@3fa9c1`. That label tells writers apart and is not verified.

Notes are kept in `~/.nightcrow/memory/<project key>.db`, one SQLite file per project and never inside the repository; set `NIGHTCROW_MEMORY_DIR` to keep them elsewhere. A note or post is at most 4 KiB, a note takes up to 8 tags, a project holds up to 2000 notes, and a read returns at most 50. Text over a limit is refused, not cut, and a full project refuses new notes until some are deleted. `nightcrow-memory export`, run in a pane, prints the project's board and notes as Markdown.

Three things to know before turning it on:

- **A note is another agent's words.** What one agent writes becomes part of what another reads, so text an agent picked up from a web page or a file can travel between panes through a note. Every read is prefixed with a notice that entries are unverified peer notes and not instructions, which lowers this risk and does not remove it.
- **Notes are not private or authenticated.** Any process running as you can read and write the file. The directory is created owner-only on Unix.
- **Agents write what they choose to.** Nothing scans a note for credentials. Review with `export` and remove with `memory_delete`.

## Recovery controls

A pending recovery is shown on the pane tab and in the browser. Use `<prefix> 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.
2 changes: 1 addition & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ node scripts/prepare-release.mjs
node scripts/prepare-release.mjs --json
```

On a clean branch, use `--execute` to update all five version entries. The application version is written once, in the root manifest's `[workspace.package]`; both crates inherit it, so the recovery plugin has no version of its own to bump. The lockfile still records each crate separately. The tool reads tags from the authoritative `code0xff/nightcrow` remote (or the official HTTPS URL when that remote is not configured), never from a stale local tag list; tag-network or authentication failures stop the command. The first no-tag release is `0.1.1`; after that, the next version is exactly one patch above the highest official `v0.1.*` tag. An explicit `--version` is accepted only when it matches that calculated value.
On a clean branch, use `--execute` to update all six version entries. The application version is written once, in the root manifest's `[workspace.package]`; every crate inherits it, so the crates under `plugins/` have no version of their own to bump. The lockfile still records each crate separately. The tool reads tags from the authoritative `code0xff/nightcrow` remote (or the official HTTPS URL when that remote is not configured), never from a stale local tag list; tag-network or authentication failures stop the command. The first no-tag release is `0.1.1`; after that, the next version is exactly one patch above the highest official `v0.1.*` tag. An explicit `--version` is accepted only when it matches that calculated value.

```bash
node scripts/prepare-release.mjs --execute
Expand Down
2 changes: 2 additions & 0 deletions plugins/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

이 문서는 `plugins/` 아래 독립적으로 빌드되는 plugin crate에 적용한다. 저장소 공통 규칙은 [루트 AGENTS.md](../AGENTS.md)를 따르고, plugin 계약의 기준은 [Plugins](../docs/plugins.md)와 [Plugin Host](../docs/architecture/plugin-host.md)다.

`nightcrow-memory`는 예외적으로 host가 실행하는 plugin이 아니라 pane 안의 provider가 띄우는 MCP helper다. 아래 host 경계 중 plugin protocol에 관한 항목은 적용되지 않지만, repository 인스턴스를 섞지 않는 것과 pane token을 인증으로 쓰지 않는 것은 똑같이 지킨다. 계약은 [Plugin Host](../docs/architecture/plugin-host.md#pane-helper-shared-memory)에 있다.

## Host 경계

- Plugin은 host 주소 공간에 들어가는 library가 아니라 별도 실행 프로세스다. host 내부 모듈이나 Rust ABI에 의존하지 말고, stdin/stdout의 NDJSON과 명시적 protocol version으로만 통신한다. 와이어 형태를 호환되지 않게 바꾸면 양쪽 계약을 함께 갱신하고 version mismatch를 추측으로 복구하지 않는다.
Expand Down
22 changes: 22 additions & 0 deletions plugins/nightcrow-memory/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "nightcrow-memory"
version.workspace = true
edition = "2024"
rust-version = "1.89"
description = "nightcrow helper: notes that the coding agents in one repository's panes share over MCP"
license = "Apache-2.0"
publish = false

[dependencies]
# The same line-and-JSON footing as the recovery plugin: an MCP stdio server is
# newline-delimited JSON-RPC, so no SDK and no async runtime.
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# The shared store. `bundled` compiles SQLite in rather than linking the
# system's, so every platform gets the same build — with FTS5 — instead of
# whatever the OS ships. Selection notes are in docs/decisions.md.
rusqlite = { version = "0.40", features = ["bundled"] }

[dev-dependencies]
tempfile = "3"
Loading
Loading