# Khoá phiên khi nhiều agent cùng làm việc

> A hook of the AI tooling writes build/relaunch/file locks so parallel sessions do not destroy each other's work; fails open.

# Session-lock protocol (chạy nhiều session Claude song song)

## Mục đích

Cho phép **nhiều session Claude cùng sửa một working tree** tại cùng một thời điểm, chạy từ bên trong plugin ai-chat của chính bản Kazuo AI đang mở, mà không session nào giẫm chân session khác và không session nào restart app giữa chừng.

Bài toán cụ thể: một vòng hardening được chia thành 5 luồng việc (stream) chạy song song. Cả 5 dùng chung một checkout, một tiến trình Electron, một thư mục `dist/`. Ba thứ sẽ hỏng nếu không có gì điều phối:

1. Hai session sửa cùng một file → mất thay đổi của nhau.
2. Một session restart/kill Electron → **mọi** session ai-chat chết theo (session là tiến trình con của `electron.exe`).
3. Hai session cùng `vite build` → `dist/` lẫn lộn hai bản build.

Giao thức giải quyết bằng: mỗi stream khai báo **file mình sở hữu** trong frontmatter của plan, và một hook `PreToolUse` chặn thao tác vượt ranh giới đó cũng như các lệnh có sức phá hoại lan rộng.

Hook là **công cụ điều phối, không phải hàng rào bảo mật**. Nó fail-open khi gặp lỗi nội bộ — các quy tắc viết trong `session-lock/PROTOCOL.md` vẫn là thứ ràng buộc thật.

## File liên quan

- `scripts/session-lock/lib.js` — dùng chung: chuẩn hoá path về dạng repo-relative POSIX, glob matcher tối giản (`*`, `**`, tiền tố `!` để loại trừ), đọc `owns:` từ frontmatter các plan `plans/*-ms-NN-*/plan.md`, đọc/ghi lock kiểu atomic (ghi file tạm rồi `rename`).
- `scripts/session-lock/guard.js` — hook `PreToolUse`. Đọc JSON từ stdin, `exit 0` = cho phép, `exit 2` = chặn kèm lý do trên stderr. Mọi lỗi nội bộ → `exit 0`.
- `scripts/session-lock/claim.js` — lệnh đầu tiên của mỗi session. Không tự ghi lock (xem *Cách hoạt động*), chỉ kiểm tra và in quy tắc.
- `scripts/session-lock/release.js` — cổng kết thúc: bắt buộc 2 typecheck sạch + mọi file mình sở hữu đã commit, rồi mới ghi `<stream>.done`.
- `scripts/session-lock/status.js` — bảng trạng thái; `--wait` chặn tới khi mọi stream đã release (dùng bởi `run.bat [W]`).
- `scripts/session-lock/wait-milestone.js` — chờ một milestone hoặc một stream khác xong.
- `scripts/session-lock/build-renderer.js` — cách **duy nhất** được phép build renderer; giữ `build.lock` để tuần tự hoá.
- `scripts/session-lock/reload-plugin.js` — nạp lại backend một plugin không cần restart: typecheck → emit → copy `main/` sang userData → toggle 2 lần → broadcast. **Từ chối `ai-chat`.**
- `scripts/session-lock/selftest.js` — 144 kiểm tra cho `guard.js`. Chạy sau mỗi lần sửa guard. Nhóm "shared working tree detection" trỏ `APPDATA` vào thư mục tạm để không đụng dữ liệu thật của app.
- `.claude/settings.json` — đăng ký hook với `matcher: ""` (phủ mọi tool).
- `session-lock/PROTOCOL.md` — bản rút gọn dán cho mỗi session; file duy nhất trong `session-lock/` được commit.
- `run.bat` / `run.sh` — thêm phím `[W]`.
- `.gitignore` — bỏ qua `session-lock/*` trừ `PROTOCOL.md`.

## Cách hoạt động

### Claim: vì sao hook ghi lock chứ không phải claim.js

Chỉ hook mới nhận được `session_id` (Claude Code truyền qua stdin); bản thân agent không biết id của chính mình. Một lock không có danh tính thì không phân biệt được hai session, nên:

1. Session chạy `node scripts/session-lock/claim.js <stream>`.
2. Hook **nhìn thấy lệnh đó trước khi nó chạy**, khớp regex `claim\.js\s+(\S+)`, và tự ghi `session-lock/<stream>.lock` kèm `sessionId` thật + `owns` đọc từ plan.
3. Sau đó `claim.js` mới chạy, đọc lock vừa được tạo và in ra quy tắc. Nếu không thấy lock → hook không chạy → báo lỗi ngay.

Nếu stream đã bị session khác giữ và còn sống, hook chặn luôn ở bước 2.

### Chặn theo quyền sở hữu

Áp cho **mọi đường ghi mà hook nhìn thấy**: `Edit` / `Write` / `MultiEdit` / `NotebookEdit`, và cả MCP `write_project_file` / `write_plugin_source` (hai tool này ghi file y hệt Edit, bỏ sót là hở toang). Hook lấy path, đưa về dạng repo-relative, rồi so với `owns` của **mọi lock khác đang sống**. Khớp → chặn. Quy tắc phụ:

- Lock của chính mình luôn thắng (không tự chặn mình).
- Path không thuộc ai → cho qua. Trong vòng này đó là các thư mục đóng băng (`docs/`, `package.json`, …), quy ước cấm sửa nằm ở PROTOCOL.md chứ hook không ép.
- Path ngoài repo → cho qua.
- Lock đã có `.done`, hoặc heartbeat quá 30 phút → không còn chặn nữa (chống lock chết khi session crash).

`owns` hỗ trợ loại trừ: `electron/**` cộng `!electron/store.ts` nghĩa là "cả electron trừ store.ts". Loại trừ luôn thắng, không phụ thuộc thứ tự.

### Heartbeat

Mọi tool call đều làm hook cập nhật `heartbeatAt` của lock thuộc session đó. Nhờ vậy "còn sống" đo bằng hoạt động thật, không cần tiến trình nền.

**Cái bẫy đi kèm:** chờ (`wait-milestone.js`) là **một** tool call rất dài — suốt thời gian đó không có tool call nào khác, nên lock tự stale sau 30 phút. Session làm đúng quy trình lại bị phạt: stream khác được phép sửa file của nó, session khác claim được stream của nó, và tới 60 phút thì `[W]` coi vòng chạy đã xong rồi **restart app ngay dưới chân nó**. Vì vậy `wait-milestone.js` có `--stream <của mình>` để tự đập nhịp mỗi vòng poll, và **từ chối** chờ quá 25 phút nếu không có cờ đó.

### Bảo vệ chính giao thức

`session-lock/**`, `scripts/session-lock/**`, `.claude/settings.json` bị đóng băng với mọi stream **trừ chủ sở hữu** khi đang có vòng chạy. Lý do: stream nào sửa được `guard.js` hoặc `settings.json` thì tắt được hàng rào của tất cả; stream nào ghi được `<stream>.done` thì làm `[W]` restart app trong lúc người khác còn đang làm. Tương tự, `release.js <stream>` bị chặn nếu lock của stream đó thuộc session khác.

Khi **không** có lock nào đang sống thì các file này sửa được bình thường — nếu không thì không ai bảo trì được giao thức.

### Chặn theo sức phá hoại

Với `Bash`, hook đối chiếu command với danh sách regex cấm; với MCP thì đối chiếu tên tool. Nhóm chính:

| Nhóm | Vì sao cấm |
|---|---|
| `taskkill`/`Stop-Process` electron, `run.bat`, `dev-rebuild`, `electron .`, `/app/relaunch` | Restart app = giết mọi session ai-chat. |
| `tsc -p tsconfig.node.json` không kèm `--noEmit` | Electron đang giữ handle `dist-electron/`; ghi đè làm hỏng app đang chạy. |
| `vite build` ngoài `build-renderer.js`, `npm run build\|dev\|start\|headless` | Hai build song song làm lẫn `dist/`. |
| `plugins/toggle\|install\|uninstall` với `ai-chat`, `live_plugin_toggle` với `ai-chat` | `deactivate()` của ai-chat gọi `disposeAll()` → chấm dứt mọi session Claude đang chạy. |
| `git checkout\|switch\|reset --hard\|stash\|rebase\|merge\|clean` | Working tree dùng chung; đổi HEAD sẽ xoá việc của session khác. **Chỉ chặn khi thực sự còn session khác** — xem "Phát hiện session khác" bên dưới. |
| `git add .`/`-A` | Stage bừa lên cả sửa đổi dở của session khác. Chặn **không điều kiện**: kể cả một mình, `git add <path>` vẫn là thói quen đúng. |
| `rm -rf dist`/`dist-electron` | Xoá thư mục Electron đang đọc. |
| MCP `kill_process`, `build_project`, `execute_command`, `terminal_execute`, … | Chạy lệnh tuỳ ý ngoài tầm kiểm soát của guard. |

### Phát hiện session khác (luật git tự mở khi chỉ còn một mình)

Nhóm luật git đổi HEAD hỏi `lib.liveAiChatSessions()` xem **còn ai đang làm việc
trong cây này không**, thay vì chặn cứng như trước.

**Vì sao không dùng lock.** Lock chỉ sinh ra khi session chạy `claim.js`. Đã gặp
thực tế 8 tiến trình `claude` chạy song song trong khi `status.js` báo "No stream
has claimed a lock" — nếu luật git tin vào lock, nó sẽ mở cửa đúng lúc nguy hiểm
nhất. Lock trả lời được "ai sở hữu file nào", không trả lời được "còn ai ở đây".

**Nguồn sự thật.** Plugin ai-chat công bố các tab đang mở ra
`%APPDATA%/kazuo-ai/ai-chat-live.json` (xem
`plugins-library/ai-chat/.claude/features/live-sessions-file.md`). Hook lọc theo
`cwd` — khớp `ROOT` **hoặc thư mục con của nó**, vì session chạy trong
`plugins-library/` vẫn dùng chung cây này.

**Ba câu trả lời:**

| `liveAiChatSessions()` | Nghĩa | Luật git |
|---|---|---|
| `null` | Không biết: chưa có file, file hỏng, hoặc `pid` trong file đã chết | **Chặn** |
| Không có dòng nào khác `session_id` của mình | Chỉ còn mình | **Cho qua** |
| Có dòng khác | Còn người khác | **Chặn** |

`null` phải chặn: trước khi app được restart với tính năng này thì file **không
tồn tại**, và đọc "không có file" thành "không có ai" sẽ mở khoá cây đúng lúc
session khác đang sửa dở.

**`pid` thay cho ngưỡng thời gian.** App bị giết thì file ở lại, mô tả những
phiên đã chết theo nó. `process.kill(pid, 0)` phân biệt xác chết với app đang
sống trong vài micro giây — không cần đợi hết hạn như lock (30 phút), và không
cần đoán "im lặng bao lâu thì coi là đã đóng".

**Tự nhận ra mình.** Hook so `session_id` trong payload với `sessionId` từng
dòng. Thiếu bước này thì một session chạy từ terminal (không có dòng nào của
mình) sẽ đọc "1 tab đang mở" thành "chỉ có mình" và mở cửa sai.

**Giới hạn:** chỉ thấy session ai-chat. Session chạy từ terminal thường vô hình
với mọi người, kể cả chính nó — nên một session terminal cộng một tab ai-chat
vẫn đọc ra "có một session khác" và vẫn chặn. Sai về phía an toàn.

Chỉ nhóm luật git được nới. Các luật còn lại (restart Electron, toggle `ai-chat`,
`vite build`, `rm -rf dist`, `git add -A`) vẫn chặn vô điều kiện — chúng phá
hỏng app hoặc chính session đang chạy, không phụ thuộc có bao nhiêu người.

### Verify mà không restart

| Thay đổi ở | Cách thấy kết quả |
|---|---|
| UI plugin (html/css/js) | Reload webview — phục vụ trực tiếp từ `plugins-library/` với `Cache-Control: no-store`. |
| Backend plugin (`main/`) | `reload-plugin.js <id>` |
| Renderer (`src/`) | `build-renderer.js` rồi reload cửa sổ qua CDP 9222 |
| Theme / ngôn ngữ | `POST /settings` (bridge server, loopback) |
| Main process (`electron/`) | Không verify live được. Typecheck + `node --test`, ghi vào checklist sau restart. |

### Kết thúc vòng

Khi mọi stream có `.done`, người dùng bấm `[W]` trong `run.bat`. Nó chờ đúng điều kiện đó (ngưỡng stale 60 phút), dời `*.lock`, `*.done`, `milestones/` vào `session-lock/history/<timestamp>/`, rồi rebuild + restart như `[R]`. Sau restart mỗi session claim lại (`round` tăng theo số thư mục trong `history/`) và chạy checklist "sau restart" trong plan của mình.

## Giới hạn

- **Bash không kiểm tra path.** Guard chỉ so khớp chuỗi lệnh. Một session vẫn có thể sửa file của người khác bằng `sed -i`, `cp`, hay `> file`. Đây là công cụ điều phối, không phải sandbox — nó chặn nhầm lẫn, không chặn được người cố tình.
- **Cách viết luật quyết định độ chính xác.** Luật "chạy cái gì" (`run.bat`, `npm run build`, `vite build`, `git checkout`, `electron .`, `release.js`) chỉ khớp ở **đầu đoạn lệnh**, sau khi bóc các lớp bọc (`bash`, `npx`, `cmd /c`, `FOO=1`, `rtk`). Luật về cờ/đường dẫn khớp trên bản **đã xoá nội dung trong ngoặc**, nên `git commit -m "wrap vite build"` hay `grep "app/relaunch"` không bị chặn. Chỉ luật cần đọc payload (bắt `ai-chat` trong body JSON của curl) mới nhìn chuỗi thô.
- **Build trong `docker run` được phép** (`blankContainerPayloads` trong `guard.js`): phần trong ngoặc của đoạn `docker run` bị làm trống trước khi tách `heads`, nên `npm run build...` chạy bên trong container không bị coi là lệnh trên máy. Chỉ áp dụng khi container không chạm được file của app đang chạy: mọi `-v` phải là **named volume** hoặc bind mount **`:ro`**; có bind mount ghi được, `--mount` hay `--volumes-from` thì vẫn chặn như cũ. `docker exec` không được miễn (không biết container đó mount gì). Phần ngoài ngoặc không bị làm trống, nên `docker run ... && npm run build:win` vẫn bị chặn. Lý do cần luật này: build bản Linux (AppImage) chạy trong `electronuserland/builder` trên volume `kazuo-linux`, tách hẳn khỏi `dist/`/`dist-electron/` mà app dev đang chạy (`node_modules/electron`) đọc. Ngược lại `npm run build:win` ở thư mục gốc **vẫn phải chặn**, vì nó ghi đè đúng những file đó.
- **Giới hạn đã biết:** luật `npm run build` chưa bắt được mọi cách bọc lệnh, và hook chỉ xét tool `Bash`, không xét tool `PowerShell`.
- **Heredoc chỉ trơ khi thứ đọc nó cũng trơ.** `cat > f <<EOF` là dữ liệu → bóc. `bash <<EOF` và `cat <<EOF | bash` là **script thật đang chạy** → giữ nguyên để soi, nếu không thì mọi lệnh cấm chỉ cần núp sau 2 dòng heredoc là thoát. Heredoc không tìm thấy dấu đóng cũng không bóc.
- **`release.js` bắt buộc tên stream viết tường minh.** Bản thân script không biết mình là session nào (chỉ hook nhận được `session_id`), nên phép so khớp văn bản trong hook **là toàn bộ hàng rào**. Tên stream tính động (`$S`, `$(...)`) thì hook không kiểm được → **chặn**, vì ghi giả `.done` của người khác sẽ khiến `[W]` restart app trong lúc họ còn đang làm.
- **`tsc -p tsconfig.plugins.json` ghi `main/*.js` của *mọi* plugin**, không chỉ plugin đang cần. Nếu mọi `.ts` đều sạch thì output y hệt, không ai thấy gì. Nhưng nếu stream khác đang có `.ts` sửa dở trong cây thì lần emit này sinh lại `.js` của họ → bẩn `git status` của họ và chặn `release.js` của họ. `reload-plugin.js` so `git status` trước/sau và **in ra danh sách file bị ghi ngoài phạm vi plugin được yêu cầu** để người ta không phải đi mò. Bản thân bước copy thì chỉ copy `main/` của đúng plugin đó.
- **`ai-chat` không reload backend được** trong vòng chạy. Thay đổi trong `plugins-library/ai-chat/main/` phải chờ restart cuối vòng.
- **`POST /broadcast` nay đã nhận `{channel, payload}`** (thêm ở MS-01, `electron/bridge-server.ts` mục "Broadcast"): có `channel` thì phát thẳng tới mọi cửa sổ trên đúng kênh đó; không có thì vẫn phát tín hiệu settings-changed chung như trước. **`reload-plugin.js` vẫn gọi `POST /broadcast` không kèm `channel`** (`scripts/session-lock/reload-plugin.js`), nên hiện tại nó chỉ đang dùng nhánh settings-changed chung, chưa tận dụng kênh riêng theo plugin — vẫn đủ để widget đang mở tự đọc lại state, nhưng chưa phải reload theo đúng kênh của plugin đó.
- **File không thuộc ai thì không được bảo vệ.** Hai session cùng sửa `docs/` hoặc một `.claude/features/*.md` không nằm trong `owns` nào sẽ không bị chặn.
- **Hook fail-open.** Guard lỗi → cho qua. Đổi lại là guard hỏng không bao giờ làm tê liệt cả 5 session.
- **Chỉ tác dụng với session Claude Code trong repo này.** Hook nằm ở `.claude/settings.json` (project scope). Session ai-chat dùng Agent SDK với `settingSources: ['user','project','local']` nên đọc được file này. **Đã kiểm chứng bằng chạy thật** (`scripts/session-lock/gate-ai-chat.mjs`, exit 0): kể cả khi ai-chat tự gắn hook RTK qua `options.hooks`, hook từ `settings.json` **vẫn chạy** — hai cơ chế cộng gộp, không đè nhau. Chạy lại gate sau khi nâng SDK, đổi `settings.json`, hoặc sửa cách `chat-manager.ts` dựng hooks.
