# Run-agent: gọi agent theo kiểu lập trình

> An API for other plugins to run one agent turn and get the result back, via IPC or a global service in the main process.

# Headless run-agent — chạy agent thay cho plugin khác (API dùng chung)

## Mục đích

Cho **plugin khác** (đầu tiên là `agent-review`, sau này bất kỳ automation nào) chạy một lượt Claude
Code agent **theo kiểu lập trình** và **nhận kết quả trả về**, thay vì phải thao tác qua UI widget.
Đây là mảnh còn thiếu để dựng pipeline: trước đây các kênh của ai-chat (`open-tab`/`connect-tab`/
`send`) chỉ **phát sự kiện broadcast** theo `tabId` cho webview, không trả kết quả cho người gọi.

## Hai đường gọi

1. **Kênh IPC (renderer→main)** — cho widget/settings của plugin khác gọi qua `window['kazuo-ai'].invoke(...)`.
2. **Global service (main→main)** — cho **main process** của plugin khác gọi trực tiếp, KHÔNG cần widget nào mở.
   `invoke` chỉ đi renderer→main nên main của plugin khác (vd `cron-job`) không với tới handler IPC được;
   mọi backend plugin chạy chung 1 tiến trình Electron main nên ai-chat publish hàm lên biến global.

### 1. Kênh IPC

- `plugin:ai-chat:run-agent` → `manager.runAgent(opts)` — **request→response** (Promise trả về kết quả).

### 2. Global service `globalThis.__kazuoAiChat`

- Đăng ký ở `activate()`, gỡ ở `deactivate()` (guard `version === 1` để không xoá nhầm publisher khác).
- Hình dạng: `{ version: 1, runAgent(opts): Promise<RunAgentResult> }`.
- Người gọi PHẢI guard: `const svc = globalThis.__kazuoAiChat; if (!svc?.runAgent) { /* ai-chat đang tắt */ }`.
- Cùng `RunAgentOptions`/`RunAgentResult` như kênh IPC bên dưới.

### Input (`RunAgentOptions`)

| Field | Bắt buộc | Mặc định | Ý nghĩa |
|---|---|---|---|
| `cwd` | ✅ | — | Thư mục làm việc (đường dẫn dùng được trên Windows) agent chạy trong đó |
| `prompt` | ✅ | — | Nội dung lượt user gửi cho agent |
| `pluginId` | — | `'external'` | Chỉ để đặt tên tab tạm `run:<pluginId>:<n>` |
| `sessionId` | — | `null` | Resume 1 session có sẵn thay vì tạo mới |
| `model` | — | `sonnet` | Model claude-code |
| `systemPrompt` | — | `''` | System prompt phụ (append preset claude_code) |
| `mode` | — | `bypassPermissions` | Permission mode; mặc định bypass để **không treo** vì không ai bấm cấp quyền |
| `attachedPlugins` | — | — | Danh sách pluginId có MCP tool cấp cho lượt chạy |
| `tabId` | — | tự sinh | Tab id do người gọi chọn (để tự `interrupt` sau) |
| `timeoutMs` | — | 15 phút | Giới hạn cứng; hết giờ → interrupt + trả `error:'timeout'` |
| `tools` | — | (SDK mặc định) | Allowlist tool tường minh, truyền thẳng qua SDK (không tự suy diễn gì thêm) |
| `disallowedTools` | — | (không có) | Blocklist tool tường minh, truyền thẳng qua SDK |
| `readOnlyScopeRoots` | — | (không có) | Bật **read-only review contract** — xem mục riêng bên dưới. Khác `null`/rỗng thì bỏ qua `sessionId` (luôn chạy phiên mới) |
| `workspaceUnityState` | — | `false` | Bật **workspace Unity-state contract** — chỉ đọc trong `cwd` + đúng 1 tool MCP đọc trạng thái Unity Editor của workspace đó. Xem mục riêng. Luôn phiên mới; bỏ qua `readOnlyScopeRoots` truyền kèm |
| `envProfileId` | — | (không có) | Hồ sơ môi trường (`providers['claude-code'].envProfiles`) dùng để spawn CLI — quyết định endpoint + credential. Bỏ trống = giữ nguyên hành vi cũ (rơi về `sessionDefaults`). **Id không khớp hồ sơ nào → lượt chạy trả `error`, KHÔNG âm thầm chạy bằng hồ sơ mặc định** — xem mục riêng bên dưới |
| `notifyPluginId` | — | (không có) | Phát thêm sự kiện của lượt chạy này trên `plugin:<id>:event` (ngoài `tabId`-scoped event như mọi tab) |

### Output (`RunAgentResult`)

`{ ok, tabId, sessionId, text, isError, costUsd?, error? }`
- `text` — toàn bộ **assistant text ở tầng chính** (bỏ qua lượt của subagent) gộp lại.
- `sessionId` — **resume được**: truyền lại vào lần gọi sau để tiếp tục cùng hội thoại.
- `ok=false` kèm `error` khi: thiếu SDK/cwd/prompt, cwd không tồn tại, send lỗi, hoặc timeout.

## Cách hoạt động (`chat-manager.ts` → `runAgent`)

1. Validate `sdk`/`cwd`/`prompt`; `openTab(tabId, {cwd, sessionId, ...})` (tab tạm).
2. Đăng ký 1 listener vào `runWaiters` (map `tabId → sink`). `handleAgentEvent` sau khi `emit` broadcast
   sẽ gọi luôn sink này → runAgent gom sự kiện của đúng tab đó.
3. `send(tabId, prompt)` — tự lazily connect CLI agent.
4. Gom `assistant` text (chỉ khi `!parentToolUseId`); nhớ `result` (is_error, cost) ở tầng chính.
5. Kết thúc khi nhận `done` (hoặc `error`, hoặc timeout) → **đóng tab** (dispose CLI, transcript vẫn
   còn trên đĩa để resume) → resolve Promise.

Vì sự kiện vẫn broadcast trên `plugin:ai-chat:event` theo `tabId` như mọi tab, tiến trình vẫn được
ghi vào ring buffer `traffic` phía backend (UI traffic monitor đã bị gỡ — xem README, mục "Theo dõi
traffic gửi/nhận với Claude"). Có thể dựng lại UI hiển thị sau nếu cần — đọc bằng
`plugin:ai-chat:traffic` (kéo khi cần). Ring buffer **không còn broadcast** từng mục: không còn ai
nghe từ khi gỡ UI, mà mỗi mục vẫn bị sao chép sang mọi cửa sổ. Ảnh/screenshot cũng bị bỏ phần
base64 trước khi lưu (giữ khối `image`, thay `data` bằng độ dài) để 300 mục không ghim hàng trăm MB.

## Chọn hồ sơ môi trường (`envProfileId`)

`resolveEnvOverride()` trong `chat-manager.ts` cố ý **lùi ngầm** về hồ sơ `default` khi id không khớp:

```ts
const profile = profiles.find((p) => p.id === targetId) || profiles.find((p) => p.id === DEFAULT_ENV_PROFILE);
```

Với tab của người dùng thì hợp lý — họ vừa bấm chọn từ dropdown, và một lựa chọn cũ không còn tồn tại
thì chạy tiếp còn hơn chặn. Với caller tự động thì **sai**: nó chỉ đích danh một tài khoản, và chạy bằng
tài khoản khác rồi báo thành công là che mất hoàn toàn sai sót.

Nên `runAgent()` kiểm tra tồn tại **trước khi mở tab**, và từ chối nếu không thấy:

```ts
if (envProfileId && !hasEnvProfile(ctx.getSetting('providers', {}), envProfileId)) {
  return { ...base, error: `Không có env profile "${envProfileId}" trong cấu hình ai-chat.` };
}
```

`hasEnvProfile()` nằm ở `main/env-profiles.ts` — module thuần, không import `electron`, nên test được
trực tiếp (`test/env-profiles.test.ts`, 5 test: khớp, không khớp, khoảng trắng thừa, id rỗng, config
thiếu/sai dạng). Hành vi của **tab người dùng KHÔNG đổi** — chỉ đường `runAgent()` là fail-closed.

Bỏ trống `envProfileId` giữ nguyên hành vi trước đây từng dòng một, nên mọi caller sẵn có
(agent-review, agent-role, kênh IPC `plugin:ai-chat:run-agent`, global `__kazuoAiChat.runAgent`) không
phải sửa gì: đây là 1 field tuỳ chọn thêm vào, không phải đổi hợp đồng.

## Read-only review contract (`readOnlyScopeRoots`)

Dành cho caller cần chạy agent **chỉ đọc, giới hạn đường dẫn** (vd `agent-review` review code mà
không được sửa/chạy lệnh gì, và không được đọc ra ngoài phạm vi đã xác nhận). Caller (ở tiến trình
khác, gọi qua IPC/global service) CHỈ gửi `readOnlyScopeRoots: string[]` (mảng string đơn thuần,
đường dẫn tuyệt đối đã canonical hoá) — không tự dựng `tools`/`disallowedTools`/`hooks`, vì một
`PreToolUse` hook là hàm JS, không thể vượt ranh giới IPC (structured-clone) từ renderer của plugin
khác sang main của ai-chat. Việc dựng policy hoàn toàn nằm trong `chat-manager.ts`'s
`connectAgentFor()` (in-process), gọi `tool-policy.ts`'s `buildReadOnlyReviewPolicy(scopeRoots, cwd)`.

Ba lớp, đi cùng nhau (một `disallowedTools` blocklist một mình KHÔNG deny-by-default trước danh
sách tool có thể mở rộng sau này):

1. **`tools: ['Read', 'Grep', 'Glob']`** — allowlist tường minh. Theo SDK đã cài
   (`node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts`), option này khai báo "the base set of
   available **built-in** tools", nên mảng tường minh loại hẳn Bash/Write/Edit/Task/Skill/WebFetch/
   WebSearch mà không cần liệt kê từng cái. Nhưng nó **không** kiểm soát tool MCP (tool MCP đến từ
   `mcpServers`) — MCP bị loại nhờ lớp 2 cộng với `mcpServers: undefined` và `settingSources: []`
   bên dưới, chứ không phải nhờ dòng này.
2. **`disallowedTools: ['mcp__*']`** — công tắc tắt MCP theo wildcard, phòng thủ thêm lớp 1.
3. **`pathBoundaryHook`** (trong `tool-policy.ts`) — `PreToolUse` hook chặn mọi lời gọi Read/Grep/Glob
   có target (đã resolve symlink qua `fs.realpathSync`, fail-closed nếu resolve lỗi) nằm ngoài mọi
   scope root đã xác nhận. Dùng `path.relative()` để so khớp chứa, KHÔNG dùng `startsWith` (tránh
   nhận nhầm thư mục anh em cùng tiền tố, vd `C:\proj\foo` vs `C:\proj\foobar`). Grep/Glob khi không
   truyền `path` sẽ mặc định dùng cwd của session (đúng hành vi mặc định của SDK) — hook áp dụng lại
   đúng fallback này để không bị lách qua việc bỏ trống `path`. Hook tự nó cũng deny-by-default: tool
   không nằm trong `Read/Grep/Glob` bị deny thẳng ở hook (không dựa vào việc caller có truyền đúng
   `tools` allowlist hay không); path tương đối (`file_path`/`path`) được resolve tường minh so với
   `sessionCwd` của phiên đó (KHÔNG phải `process.cwd()` của tiến trình main dùng chung cho mọi tab)
   trước khi realpath; nếu **bất kỳ** root nào trong `scopeRoots` resolve lỗi thì cả danh sách bị coi
   là không hợp lệ (deny mọi thứ) thay vì âm thầm bỏ qua mỗi root lỗi và dùng phần còn lại.

   Grep/Glob còn có 2 lớp kiểm tra riêng mà Read (luôn nhắm 1 file) không cần:
   - **Pattern traversal**: `pattern` của Glob và `glob` filter (tuỳ chọn) của Grep bị deny nếu chứa
     segment `..`, hoặc là đường dẫn tuyệt đối (POSIX hoặc `C:\`/`C:/` kiểu Windows) — các giá trị này
     do chính tool évaluate tương đối với base directory đã được hook duyệt, nên một pattern kiểu
     `'../outside/**'` có thể thoát khỏi base đó dù base đã hợp lệ.
   - **Quét symlink lồng trong thư mục**: khi target đã resolve là một **thư mục** (không phải file
     đơn), hook chạy `scanForEscapingSymlinks()` — duyệt đệ quy có giới hạn (bounded, cycle-safe qua
     `visited: Set` để tránh vòng lặp symlink) toàn bộ nội dung thư mục đó; nếu gặp bất kỳ symlink nào
     resolve ra ngoài mọi scope root → deny; đệ quy tiếp vào cả thư mục symlink nếu chính nó resolve
     trong scope (bắt được cả trường hợp thoát qua một chuỗi symlink lồng nhau); nếu số mục duyệt vượt
     `SYMLINK_SCAN_BUDGET` (5000) mà chưa xong → deny (`'too-large'`, không âm thầm dừng giữa chừng rồi
     cho qua). Đây là fix thật trong code, không phải giới hạn chỉ ghi chú lại — quét này chạy trước
     khi Grep/Glob thật sự được phép đụng vào thư mục đó.

Đi kèm bắt buộc: `settingSources: []` cho lượt connect này (không load hook/MCP server nào của
user/project/local `.claude` config), `mcpServers` bị ghi đè thành `undefined` (không khởi động
plugin-control MCP server nào), và **không resume session cũ** — `openTab()` ép `sessionId: null`
bất kể `cfg.sessionId` truyền gì, để một lượt bị giới hạn không kế thừa ngữ cảnh/cấu hình của một
session trước đó (có thể không bị giới hạn). Nếu tab đang giới hạn mà `provider !== 'claude-code'`
(vd API provider) thì `connectAgentFor()` **fail-closed** — trả lỗi thay vì âm thầm "thành công"
không có session sống như nhánh API-provider bình thường.

**Kiểm chứng thực nghiệm (2026-09-10):**
- 22/22 unit test (`tool-policy.test.ts`, `node:test`) pass, gồm test symlink-escape thật (tạo
  symlink thật bằng `fs.symlinkSync` trên máy Windows này — không phải giả lập — và xác nhận bị
  deny) cho cả trường hợp symlink làm target trực tiếp VÀ symlink lồng trong một thư mục được
  Grep/Glob quét, test pattern traversal (`'../outside/**'`, pattern tuyệt đối), test fail-closed khi
  một root hợp lệ đi kèm một root lỗi, test resolve path tương đối theo `sessionCwd`, và test vượt
  ngân sách quét (5001 file thật trong 1 thư mục → deny `'too-large'`).
- Runtime proof thật với SDK sống (`tool-policy.runtime-proof.test.ts`, opt-in qua
  `AI_CHAT_RUNTIME_PROOF=1`, dùng TEMP fixture độc lập, không đụng tiến trình ai-chat đang chạy):
  **đã chạy thành công** (~9s, model thật gọi qua API thật). Model gọi đúng 2 `tool_use`, cả hai đều
  `Read` (không có Write/Bash/Task/Skill/`mcp__*` nào xuất hiện) — 1 lần đọc file trong scope (đọc
  được), 1 lần thử đọc file NGOÀI scope (bị hook deny, nội dung decoy không rò rỉ ra tool_result hay
  câu trả lời cuối). Lưu ý môi trường: khi không truyền `pathToClaudeCodeExecutable`, SDK tự dùng CLI
  đóng gói kèm nó (trong `node_modules/@anthropic-ai/claude-agent-sdk`), bản này cũ hơn CLI hệ thống
  trên PATH và bị API từ chối (`426: client cli version 2.1.220 < required 2.1.258`) — không liên
  quan gì đến logic policy. Trỏ tường minh `pathToClaudeCodeExecutable` vào CLI hệ thống (`where
  claude` → `2.1.267`, cùng cách `claude-cli.ts`'s `resolveCliPath()` làm cho app thật) thì chạy qua.
  `AgentSession`/`connectAgentFor()` trong app thật vốn đã tự resolve `cliPath` theo cách này rồi nên
  không bị ảnh hưởng — đây chỉ là điều cần lưu ý khi tái chạy script kiểm chứng độc lập này sau này.

## Workspace Unity-state contract (`workspaceUnityState`)

Dành cho caller cần agent **chỉ đọc, giới hạn trong 1 workspace**, nhưng **có thêm đúng 1 tool MCP**:
đọc trạng thái Unity Editor của chính workspace đó. Khác `readOnlyScopeRoots` ở chỗ contract cũ tắt
sạch MCP, còn contract này phải mở đúng một cửa — và phải mở mà không làm thủng phần còn lại.

Bật bằng `workspaceUnityState: true`. Caller **không** truyền url/port/tên tool/cwd của endpoint — không
có tham số nào để truyền, và model cũng vậy.

### Endpoint đến từ đâu (`workspace-unity-state.ts` → `resolveUnityStateEndpoint`)

Host tự resolve **trước khi mở tab**, đọc đúng `<cwd>/.mcp.json` của workspace và chỉ lấy entry
`ai-game-developer`. Fail-closed toàn bộ, không đoán:

| Tình huống | Kết quả |
|---|---|
| Không có `.mcp.json`, JSON hỏng, không khai báo `ai-game-developer` | Chặn |
| `type` khác `http`, hoặc entry có `command`/`args`/`env` | Chặn (resolver không chạy gì cả) |
| URL không phải `http:` (https/file/ws), hoặc host không phải loopback | Chặn |
| Thư mục con của workspace có khai báo | Chặn — **không** kế thừa `.mcp.json` của thư mục cha |
| Có server khác trong cùng file (vd `arkon` kèm Bearer token) | Bỏ qua hoàn toàn; credential của nó không bao giờ được mang theo |

Không dò port, không quét `~/.claude.json`, không merge cấu hình. Resolve không chắc chắn → `runAgent`
trả lỗi ngay, **không** hạ cấp thành lượt chạy không giới hạn.

### Vì sao KHÔNG dùng lại `disallowedTools: ['mcp__*']`

Theo SDK đang cài (`0.3.220`), `disallowedTools` là "cannot be used, **even if they would otherwise be
allowed**" — deny thắng allow. Nên không thể chặn `mcp__*` rồi mở ngoại lệ cho proxy: wildcard sẽ giết
luôn chính tool mà chế độ này sinh ra để dùng. Contract này bỏ wildcard và thay bằng **ràng buộc cấu
trúc**, bốn lớp:

1. `mcpServers` chỉ chứa **một** server in-process (`unity-state`) với **một** tool — không có tool MCP
   nào khác tồn tại để model gọi.
2. `settingSources: []` — không nạp MCP server/hook nào của user/project/local.
3. `tools: ['Read','Grep','Glob']` — toàn bộ bề mặt built-in.
4. `PreToolUse` hook (`buildWorkspaceUnityStatePolicy`) cho qua đúng một tên
   `mcp__unity-state__editor-application-get-state`, mọi tên khác rơi xuống `pathBoundaryHook` (dùng
   chung với read-only review) và bị deny-by-default.

**Lưu ý quan trọng đã kiểm chứng:** `tools` trong SDK này chỉ khai báo *built-in tools*, **không** kiểm
soát tool MCP. Đây là lý do lớp 1–2 ở trên là bắt buộc, và cũng là lý do contract `readOnlyScopeRoots`
cũ an toàn — nó an toàn nhờ `mcpServers: undefined` + `settingSources: []` + wildcard, chứ không phải
nhờ `tools`.

Tool proxy khai báo **schema rỗng** (`{}`): không có tham số nào để model đổi hướng nó sang url, port,
cwd hay tool khác. Vì thế hook chỉ cần cho qua theo tên là đủ.

### Transport (`fetchEditorState`)

MCP Streamable HTTP: `initialize` → `notifications/initialized` → `tools/call`
(`editor-application-get-state`, arguments rỗng) → **`DELETE` dọn session** (kể cả khi tool call lỗi,
best-effort, không bao giờ tắt server). Đọc được cả `text/event-stream` lẫn JSON thuần. Có timeout
(mặc định 15s), chặn cứng kích thước phản hồi (256 KiB), `redirect: 'error'` (không đi theo 3xx sang
host chưa qua kiểm tra). Lỗi trả về dạng lý do, **không kèm request header** — auth không bao giờ lọt
vào log/tool result/prompt.

### Tương tác với agent role

Role (`attachedAgentId`) vẫn cấp **persona** (system prompt). Nhưng bundle plugin của role **bị bỏ qua**
ở chế độ này: `connectAgentFor()` dựng `mcpServers` chỉ từ proxy, không gọi
`buildPluginControlMcpServers`, nên role không thể cộng thêm tool vào một lượt chạy bị giới hạn.

Ngoài ra: luôn phiên mới (`openTab()` ép `sessionId: null`), chỉ hỗ trợ provider `claude-code`
(fail-closed nếu khác), và nếu caller truyền kèm `readOnlyScopeRoots` thì giá trị đó **bị bỏ qua** —
một tab chỉ chịu một contract.

### Tình trạng kiểm chứng (2026-09-11)

- Unit test: 39 test mới pass (`workspace-unity-state.test.ts` 12 resolver, `.transport.test.ts` 12,
  `.policy.test.ts` 10, `.mcp.test.ts` 5) + 22 test read-only review cũ vẫn pass sau khi tách helper
  `resolveScopeRoots` dùng chung trong `tool-policy.ts`.
- Runtime proof thật (`workspace-unity-state.runtime-proof.test.ts`, opt-in
  `AI_CHAT_UNITY_PROOF=1`): **đã chạy thành công** với 3 workspace game thử nghiệm → 3 endpoint Unity MCP riêng,
  role điều phối game, model thật. Mỗi lượt chỉ xuất hiện đúng 2 `tool_use`: proxy +
  1 `Read` ra ngoài scope (đã xác nhận bị hook deny với đúng đường dẫn thật). Ba editor trả
  `IsPlaying` khác nhau (true/false/false) — bằng chứng không đấu nhầm dây.
- **Đã kiểm chứng qua `runAgent()` trong app thật (2026-09-11, sau restart).** Gọi qua kênh
  `plugin:ai-chat:run-agent`, 4/4 pass: 3 workspace thử nghiệm đều `ok=true` (3.8–4.7s/lượt),
  và workspace không khai báo endpoint (`drawwindow`) bị **từ chối** đúng như thiết kế
  (`workspace-unity-state bị từ chối: Workspace does not declare an "ai-game-developer" server.`) —
  không hạ cấp thành lượt chạy không giới hạn.

  Bằng chứng lấy từ **transcript `.jsonl` của CLI** (trace thật, không phải lời agent):
  - Mỗi phiên đúng **một** `tool_use` = `mcp__unity-state__editor-application-get-state`, không có
    tool nào ngoài hợp đồng.
  - Transcript nằm đúng thư mục project của từng workspace, `cwd` khớp từng workspace.
  - `IsPlaying` khác nhau giữa 3 editor (true/false/false) → không đấu nhầm dây.
  - Lượt thử phá: model gọi `Read` ra ngoài scope → `tool_result` trả
    `Path outside confirmed review scope: ...\drawwindow\package.json` (đúng chuỗi hook sinh ra),
    nội dung file không rò rỉ; model báo **không có tool Bash** trong phiên.

  Lưu ý: ring buffer `traffic` KHÔNG ghi tab của `runAgent` (chỉ ghi tab UI), nên đừng dùng nó làm
  nguồn trace cho chế độ này — dùng transcript `.jsonl` theo `sessionId` mà `runAgent` trả về.
- Trạng thái editor **không** trả project path (chỉ có đường dẫn cài Unity, giống nhau cả 3 máy).
  Không được kết luận "đã xác minh danh tính project" chỉ từ state/port.
- `readOnlyHint` của MCP **không** phải sandbox bảo mật chống tiến trình cùng OS user.

## Lưu ý / giới hạn

- 1 lần gọi = **1 lượt** (single turn). Muốn nhiều lượt → gọi lại với `sessionId` trả về.
- Mặc định `bypassPermissions` (`allowDangerouslySkipPermissions`): agent tự chạy tool không hỏi phép.
  Nếu cần chế độ hỏi phép, truyền `mode` khác — nhưng permission_request chỉ có UI ai-chat trả lời được.
- Sau khi xong tab bị `closeTab` (kill CLI). Không giữ tiến trình sống giữa các lần gọi.
- Chưa có UI trong widget ai-chat để hiển thị riêng các tab `run:*` — chỉ còn ghi vào ring buffer
  `traffic` phía backend (UI traffic monitor đã gỡ).

## Liên quan

- Muốn **hiển thị** hội thoại streaming (không chỉ lấy kết quả cuối) trong widget của plugin khác →
  xem `.claude/features/embeddable-chat-ui.md` (mount UI chat qua `window.KazuoChatEmbed`, dùng chung
  cơ chế `notifyPluginId` để định tuyến sự kiện tab).
- **Bảo vệ workspace của `agent-review`**: `deleteWorkspace()` (`chat-manager.ts`) từ chối xóa bất kỳ
  thư mục nào nằm dưới `~/.kazuo/agent-review/reviews/`, trả `{ protected: true }` thay vì xóa —
  workspace review tạo bởi `run-agent` không bị người dùng lỡ tay xóa mất báo cáo. Xóa **1 session**
  bên trong workspace đó vẫn được (chỉ chặn xóa cả thư mục). Chi tiết pipeline gọi `run-agent`:
  `.claude/features/agent-review-plugin.md`.
